diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 4dfb909..f9afcf7 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,9 +1,14 @@ name: Backend CI on: - push: + pull_request: branches: - develop + types: + - opened + - synchronize + - reopened + - ready_for_review paths: - 'backend/**' - '.github/workflows/backend-ci.yml' @@ -12,7 +17,7 @@ permissions: contents: read concurrency: - group: secondzip-backend-ci + group: secondzip-backend-ci-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: @@ -37,4 +42,4 @@ jobs: run: chmod +x backend/gradlew - name: Run tests - run: ./backend/gradlew -p backend clean test --no-daemon \ No newline at end of file + run: ./backend/gradlew -p backend clean test --no-daemon diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..f2aa288 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,45 @@ +name: Frontend CI + +on: + pull_request: + branches: + - develop + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + +permissions: + contents: read + +concurrency: + group: secondzip-frontend-ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Run tests + run: npm run test:coverage + working-directory: frontend + diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index 672c6e4..85d771e 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -13,6 +13,7 @@ on: permissions: contents: read packages: write + id-token: write concurrency: group: secondzip-frontend-production @@ -26,6 +27,21 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Run tests + run: npm run test:coverage + working-directory: frontend + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -42,6 +58,7 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true + platforms: linux/amd64 tags: | ghcr.io/second-zip/second-zip-frontend:latest @@ -55,3 +72,32 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v6.2.3 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Deploy frontend to EC2 + run: | + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "${{ vars.EC2_INSTANCE_ID }}" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy frontend ${GITHUB_SHA}" \ + --parameters 'commands=[ + "sudo -u ec2-user -H bash -lc \"cd /home/ec2-user/second-zip-app && docker compose -f docker/docker-compose.prod.yml pull frontend && docker compose -f docker/docker-compose.prod.yml up -d frontend\"" + ]' \ + --query "Command.CommandId" \ + --output text) + + echo "SSM Command ID: $COMMAND_ID" + + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "${{ vars.EC2_INSTANCE_ID }}" + + aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "${{ vars.EC2_INSTANCE_ID }}" \ + --query '{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}' diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js new file mode 100644 index 0000000..49feb37 --- /dev/null +++ b/frontend/src/App.test.js @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +import App from './App.vue'; + +describe('App', () => { + it('현재 라우트 화면을 렌더링한다', () => { + const wrapper = mount(App, { + global: { + stubs: { RouterView: { template: '
' } }, + }, + }); + + expect(wrapper.get('[data-test="route-view"]').exists()).toBe(true); + }); +}); diff --git a/frontend/src/api/instance.js b/frontend/src/api/instance.js index 76f7f40..7577b8b 100644 --- a/frontend/src/api/instance.js +++ b/frontend/src/api/instance.js @@ -24,11 +24,11 @@ api.interceptors.request.use((config) => { return config; }); -// 재발급 API가 없으므로 401 응답에서는 인증 정보를 정리하고 로그인으로 이동한다. +// 재발급 API가 없으므로 인증이 필요한 응답에서는 인증 정보를 정리하고 로그인으로 이동한다. api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { + if ([401, 403].includes(error.response?.status)) { removeAccessToken(); window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT)); } diff --git a/frontend/src/api/instance.test.js b/frontend/src/api/instance.test.js new file mode 100644 index 0000000..5c70195 --- /dev/null +++ b/frontend/src/api/instance.test.js @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import api, { AUTH_UNAUTHORIZED_EVENT } from './instance'; +import { getAccessToken, removeAccessToken } from './token'; + +const { requestUse, responseUse } = vi.hoisted(() => ({ + requestUse: vi.fn(), + responseUse: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + interceptors: { + request: { use: requestUse }, + response: { use: responseUse }, + }, + })), + }, +})); + +vi.mock('./token', () => ({ + getAccessToken: vi.fn(), + removeAccessToken: vi.fn(), +})); + +const handleError = responseUse.mock.calls[0][1]; +const handleResponse = responseUse.mock.calls[0][0]; +const handleRequest = requestUse.mock.calls[0][0]; + +describe('API response interceptor', () => { + beforeEach(() => { + vi.restoreAllMocks(); + getAccessToken.mockReset(); + removeAccessToken.mockClear(); + }); + + it('저장된 토큰을 Authorization 헤더에 추가한다', () => { + getAccessToken.mockReturnValue('access-token'); + const config = { headers: { set: vi.fn() } }; + + expect(handleRequest(config)).toBe(config); + expect(config.headers.set).toHaveBeenCalledWith( + 'Authorization', + 'Bearer access-token', + ); + }); + + it('토큰이 없으면 Authorization 헤더를 추가하지 않는다', () => { + const config = { headers: { set: vi.fn() } }; + + expect(handleRequest(config)).toBe(config); + expect(config.headers.set).not.toHaveBeenCalled(); + }); + + it('성공 응답을 그대로 반환한다', () => { + const response = { data: { ok: true } }; + + expect(handleResponse(response)).toBe(response); + }); + + it.each([401, 403])('%i 응답이면 인증 정보를 정리하고 로그인 이동 이벤트를 발생시킨다', async (status) => { + const error = { response: { status } }; + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + + await expect(handleError(error)).rejects.toBe(error); + + expect(removeAccessToken).toHaveBeenCalledOnce(); + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: AUTH_UNAUTHORIZED_EVENT }), + ); + }); + + it('401/403이 아닌 오류는 인증 상태를 변경하지 않는다', async () => { + const error = { response: { status: 500 } }; + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + + await expect(handleError(error)).rejects.toBe(error); + + expect(removeAccessToken).not.toHaveBeenCalled(); + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('API 인스턴스를 생성한다', () => { + expect(api).toBeDefined(); + }); +}); diff --git a/frontend/src/api/terms.test.js b/frontend/src/api/terms.test.js new file mode 100644 index 0000000..78d9f6d --- /dev/null +++ b/frontend/src/api/terms.test.js @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import api from './instance'; +import { getConsents, getLatestTerms } from './terms'; + +vi.mock('./instance', () => ({ + default: { get: vi.fn() }, +})); + +describe('약관 API', () => { + beforeEach(() => vi.clearAllMocks()); + + it('개인정보 동의 상태를 조회한다', async () => { + const data = { privacyPolicy: true }; + api.get.mockResolvedValue({ data }); + + await expect(getConsents()).resolves.toBe(data); + expect(api.get).toHaveBeenCalledWith('/terms/consents'); + }); + + it('최신 약관과 고지를 조회한다', async () => { + const data = { terms: [], notices: [] }; + api.get.mockResolvedValue({ data }); + + await expect(getLatestTerms()).resolves.toBe(data); + expect(api.get).toHaveBeenCalledWith('/terms/latest'); + }); +}); diff --git a/frontend/src/components/auth/AuthInputBox.spec.js b/frontend/src/components/auth/AuthInputBox.test.js similarity index 100% rename from frontend/src/components/auth/AuthInputBox.spec.js rename to frontend/src/components/auth/AuthInputBox.test.js diff --git a/frontend/src/components/dictionary/LegacyDictionaryComponents.test.js b/frontend/src/components/dictionary/LegacyDictionaryComponents.test.js new file mode 100644 index 0000000..6d50ba6 --- /dev/null +++ b/frontend/src/components/dictionary/LegacyDictionaryComponents.test.js @@ -0,0 +1,32 @@ +import { mount, RouterLinkStub } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +import FFraudGuide from './fraud/F_FraudGuide.vue'; +import FDictionaryGuide from './main/F_DictionaryGuide.vue'; +import FWordGuide from './words/F_WordGuide.vue'; +import DictSubHeader from '@/layouts/DictSubHeader.vue'; + +describe('도감 표시용 컴포넌트', () => { + it.each([ + [FFraudGuide, 'aside'], + [FDictionaryGuide, '.dictionary-guide'], + [FWordGuide, '.word-guide'], + ])('비서 이미지와 메시지를 표시한다', (component, selector) => { + const wrapper = mount(component, { + props: { image: '/character.png', message: '안내 메시지' }, + }); + + expect(wrapper.get(selector).text()).toContain('안내 메시지'); + expect(wrapper.get('img').attributes('src')).toBe('/character.png'); + }); + + it('하위 도감 헤더를 표시한다', () => { + const wrapper = mount(DictSubHeader, { + props: { title: '현재 도감' }, + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + + expect(wrapper.text()).toContain('전세사기 도감'); + expect(wrapper.text()).toContain('현재 도감'); + }); +}); diff --git a/frontend/src/components/dictionary/dictionary.spec.js b/frontend/src/components/dictionary/dictionary.test.js similarity index 84% rename from frontend/src/components/dictionary/dictionary.spec.js rename to frontend/src/components/dictionary/dictionary.test.js index f4fed31..4e68226 100644 --- a/frontend/src/components/dictionary/dictionary.spec.js +++ b/frontend/src/components/dictionary/dictionary.test.js @@ -75,6 +75,25 @@ describe('fraud dictionary components', () => { ); }); + it('고해상도 썸네일 로딩 실패 시 기본 썸네일로 교체한다', async () => { + const wrapper = mount(CFraudTypeCard, { + props: { + type: { + ...fraudType, + thumbnailSrc: '/maxres.jpg', + thumbnailFallbackSrc: '/fallback.jpg', + }, + }, + }); + const image = wrapper.get('button img'); + + await image.trigger('error'); + expect(image.element.src).toContain('/fallback.jpg'); + + await image.trigger('error'); + expect(image.element.dataset.fallbackApplied).toBe('true'); + }); + it('keeps a 9:16 placeholder until a video source is provided', () => { const wrapper = mount(BFraudVideoPlayer, { props: { fraudType }, @@ -264,6 +283,40 @@ describe('comic guide components', () => { expect(wrapper.classes()).not.toContain('is-dragging'); }); + it('이미지 로드 시 실제 콘텐츠 높이로 페이지 비율을 보정한다', async () => { + const wrapper = mount(CComicScroller, { + props: { + tab: { + id: 'registry', + images: [{ id: 'page-trim', src: '/trim-content.png', alt: 'comic' }], + }, + }, + }); + const image = wrapper.get('img'); + Object.defineProperties(image.element, { + naturalWidth: { configurable: true, value: 10 }, + naturalHeight: { configurable: true, value: 20 }, + }); + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation( + (tagName, options) => tagName === 'canvas' + ? { + getContext: () => ({ + drawImage: vi.fn(), + getImageData: () => ({ data: new Uint8ClampedArray(10 * 20 * 4) }), + }), + } + : originalCreateElement(tagName, options), + ); + + await image.trigger('load'); + + expect(wrapper.get('.comic-scroller__page').attributes('style')).toContain( + 'aspect-ratio: 10 / 20', + ); + createElementSpy.mockRestore(); + }); + it('collapses the assistant message to the current zoom percentage', () => { const wrapper = mount(FGuideCharacter, { props: { diff --git a/frontend/src/components/layout/BottomNavigation.test.js b/frontend/src/components/layout/BottomNavigation.test.js index 5f1e4aa..79a03e7 100644 --- a/frontend/src/components/layout/BottomNavigation.test.js +++ b/frontend/src/components/layout/BottomNavigation.test.js @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import ChecklistBlueIcon from '@/assets/icons/nav/checklist-blue-22.svg'; import ChecklistGrayIcon from '@/assets/icons/nav/checklist-gray-22.svg'; +import DictionaryBlueIcon from '@/assets/icons/nav/dict-blue-22.svg'; import MyPageBlueIcon from '@/assets/icons/nav/mypage-blue-22.svg'; import MainLogo from '@/assets/images/main-logo.png'; import BottomNavigation from './BottomNavigation.vue'; @@ -56,6 +57,24 @@ describe('BottomNavigation', () => { }, ); + test.each([ + '/dictionary/words', + '/dictionary/fraud', + '/dictionary/fraud/trust-property', + '/dictionary/register', + '/dictionary/move-in', + ])('%s에서 도감 메뉴를 활성화한다', (path) => { + route.path = path; + const wrapper = mount(BottomNavigation, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + const dictionary = findMenu(wrapper, '도감'); + + expect(dictionary.classes()).toContain('is-active'); + expect(dictionary.attributes('aria-current')).toBe('page'); + expect(dictionary.get('img').attributes('src')).toBe(DictionaryBlueIcon); + }); + test.each(['/mypage/profile', '/mypage/secretary'])( '%s activates the MY menu', (path) => { diff --git a/frontend/src/components/layout/BottomNavigation.vue b/frontend/src/components/layout/BottomNavigation.vue index 337df4c..a8864ff 100644 --- a/frontend/src/components/layout/BottomNavigation.vue +++ b/frontend/src/components/layout/BottomNavigation.vue @@ -23,6 +23,7 @@ const menus = [ { label: '도감', to: '/dictionary', + activePaths: ['/dictionary'], inactiveIcon: dictGray, activeIcon: dictBlue, }, diff --git a/frontend/src/components/report/create/ReportCreateForm.test.js b/frontend/src/components/report/create/ReportCreateForm.test.js index f6e4c5d..e410361 100644 --- a/frontend/src/components/report/create/ReportCreateForm.test.js +++ b/frontend/src/components/report/create/ReportCreateForm.test.js @@ -185,4 +185,15 @@ describe('ReportCreateForm', () => { expect(wrapper.find('.address-search__result').exists()).toBe(false); expect(wrapper.find('[role="alert"]').exists()).toBe(false); }); + + it('공백 검색어는 API를 호출하지 않고 검색 상태를 초기화한다', async () => { + const wrapper = mountForm(); + + wrapper.getComponent({ name: 'ReportAddressSearch' }).vm.$emit('search', ' '); + await wrapper.vm.$nextTick(); + + expect(addressApi.searchAddresses).not.toHaveBeenCalled(); + expect(wrapper.find('.address-search__result').exists()).toBe(false); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + }); }); diff --git a/frontend/src/composables/checklist/useAudioAnalyser.test.js b/frontend/src/composables/checklist/useAudioAnalyser.test.js new file mode 100644 index 0000000..a6cc52d --- /dev/null +++ b/frontend/src/composables/checklist/useAudioAnalyser.test.js @@ -0,0 +1,86 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAudioAnalyser } from './useAudioAnalyser'; + +const mountAnalyser = () => + mount({ + setup: () => useAudioAnalyser(), + template: '
', + }); + +describe('useAudioAnalyser', () => { + let analyser; + let context; + let source; + + beforeEach(() => { + analyser = { + fftSize: 0, + getByteTimeDomainData: vi.fn((samples) => samples.fill(255)), + smoothingTimeConstant: 0, + }; + source = { connect: vi.fn(), disconnect: vi.fn() }; + context = { + close: vi.fn().mockResolvedValue(undefined), + createAnalyser: vi.fn(() => analyser), + createMediaStreamSource: vi.fn(() => source), + state: 'running', + }; + vi.stubGlobal('AudioContext', vi.fn(() => context)); + vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('오디오 스트림의 볼륨을 분석해 파형 레벨을 갱신한다', () => { + const stream = { id: 'stream' }; + const wrapper = mountAnalyser(); + + wrapper.vm.startAnalysis(stream); + + expect(context.createMediaStreamSource).toHaveBeenCalledWith(stream); + expect(analyser.fftSize).toBe(256); + expect(analyser.smoothingTimeConstant).toBe(0.75); + expect(source.connect).toHaveBeenCalledWith(analyser); + expect(wrapper.vm.waveformLevels.at(-1)).toBe(1); + expect(window.requestAnimationFrame).toHaveBeenCalled(); + }); + + it('분석을 정지하면 자원과 파형을 초기화한다', async () => { + const wrapper = mountAnalyser(); + wrapper.vm.startAnalysis({}); + + wrapper.vm.stopAnalysis(); + await flushPromises(); + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(17); + expect(source.disconnect).toHaveBeenCalledOnce(); + expect(context.close).toHaveBeenCalledOnce(); + expect(wrapper.vm.waveformLevels).toEqual(Array(8).fill(0.08)); + }); + + it('AudioContext를 지원하지 않으면 안전하게 종료한다', () => { + vi.stubGlobal('AudioContext', undefined); + vi.stubGlobal('webkitAudioContext', undefined); + const wrapper = mountAnalyser(); + + expect(() => wrapper.vm.startAnalysis({})).not.toThrow(); + expect(context.createAnalyser).not.toHaveBeenCalled(); + }); + + it('컴포넌트 해제 시 분석을 정리하고 이미 닫힌 context는 다시 닫지 않는다', () => { + context.state = 'closed'; + const wrapper = mountAnalyser(); + wrapper.vm.startAnalysis({}); + + wrapper.unmount(); + + expect(source.disconnect).toHaveBeenCalledOnce(); + expect(context.close).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/composables/checklist/useAudioPlayer.test.js b/frontend/src/composables/checklist/useAudioPlayer.test.js index a33e7fd..91e025a 100644 --- a/frontend/src/composables/checklist/useAudioPlayer.test.js +++ b/frontend/src/composables/checklist/useAudioPlayer.test.js @@ -29,4 +29,65 @@ describe('useAudioPlayer', () => { expect(beforePlay).toHaveBeenCalledOnce(); expect(callOrder).toEqual(['refresh', 'play']); }); + + test('재생 중이면 오디오를 일시정지한다', async () => { + const { player } = setup(); + const audio = { pause: vi.fn(), play: vi.fn() }; + player.audioElement.value = audio; + player.isPlaying.value = true; + + await player.togglePlayback(); + + expect(audio.pause).toHaveBeenCalledOnce(); + expect(audio.play).not.toHaveBeenCalled(); + }); + + test('재생 요소가 없거나 재생이 실패해도 안전하게 처리한다', async () => { + const { player } = setup(); + + await expect(player.togglePlayback()).resolves.toBeUndefined(); + + player.audioElement.value = { + pause: vi.fn(), + play: vi.fn().mockRejectedValue(new Error('play failed')), + }; + player.isPlaying.value = true; + await player.togglePlayback(); + player.isPlaying.value = false; + await player.togglePlayback(); + + expect(player.isPlaying.value).toBe(false); + }); + + test('메타데이터와 재생 시간을 반영하고 종료 시 초기화한다', () => { + const { player } = setup(); + player.audioElement.value = { + currentTime: 30, + duration: 120, + pause: vi.fn(), + }; + + player.updateMetadata(); + player.updateCurrentTime(); + expect(player.duration.value).toBe(120); + expect(player.currentTime.value).toBe(30); + expect(player.progress.value).toBe(25); + + player.handleEnded(); + expect(player.currentTime.value).toBe(0); + expect(player.isPlaying.value).toBe(false); + }); + + test('유효하지 않은 오디오 길이는 fallback을 사용하고 해제 시 재생을 정지한다', () => { + const { player, wrapper } = setup(); + const pause = vi.fn(); + player.audioElement.value = { duration: Number.NaN, pause }; + + player.updateMetadata(); + expect(player.duration.value).toBe(0); + expect(player.progress.value).toBe(0); + + wrapper.unmount(); + expect(pause).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/composables/checklist/useAudioRecorder.test.js b/frontend/src/composables/checklist/useAudioRecorder.test.js index 700dedf..e871913 100644 --- a/frontend/src/composables/checklist/useAudioRecorder.test.js +++ b/frontend/src/composables/checklist/useAudioRecorder.test.js @@ -1,5 +1,7 @@ import { mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + afterEach, beforeEach, describe, expect, test, vi, +} from 'vitest'; import { useAudioRecorder } from './useAudioRecorder'; @@ -36,10 +38,12 @@ const setup = () => { describe('useAudioRecorder', () => { let getUserMedia; let stream; + let stopTrack; beforeEach(() => { vi.clearAllMocks(); - stream = { getTracks: () => [{ stop: vi.fn() }] }; + stopTrack = vi.fn(); + stream = { getTracks: () => [{ stop: stopTrack }] }; getUserMedia = vi.fn().mockResolvedValue(stream); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia }, @@ -48,6 +52,10 @@ describe('useAudioRecorder', () => { mocks.stopPcm.mockResolvedValue(undefined); }); + afterEach(() => { + vi.useRealTimers(); + }); + test('마이크·분석·PCM 전송을 시작하고 로컬 녹음 Blob을 만든다', async () => { const { recorder } = setup(); const beforeStart = vi.fn(); @@ -84,4 +92,60 @@ describe('useAudioRecorder', () => { expect(mocks.stopPcm).toHaveBeenCalled(); expect(mocks.stopAnalysis).toHaveBeenCalled(); }); + + test('사용 가능한 마이크가 없을 때 원인에 맞는 메시지를 표시한다', async () => { + getUserMedia.mockRejectedValue(Object.assign(new Error(), { + name: 'NotFoundError', + })); + const { recorder } = setup(); + + await expect(recorder.startRecording()).rejects.toThrow(); + + expect(recorder.errorMessage.value).toBe('사용 가능한 마이크를 찾을 수 없어요.'); + }); + + test('브라우저가 녹음을 지원하지 않을 때 일반 오류 메시지를 표시한다', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, value: undefined, + }); + const { recorder } = setup(); + + await expect(recorder.startRecording()).rejects.toThrow('not supported'); + + expect(recorder.errorMessage.value).toContain('녹음을 시작할 수 없어요.'); + }); + + test('녹음 중 경과 시간을 갱신한다', async () => { + vi.useFakeTimers(); + const { recorder } = setup(); + + await recorder.startRecording(); + await vi.advanceTimersByTimeAsync(1250); + + expect(recorder.elapsedSeconds.value).toBe(1); + await recorder.stopRecording(); + }); + + test('PCM 저장 실패 시 null을 반환하고 입력 리소스를 정리한다', async () => { + const { recorder } = setup(); + await recorder.startRecording(); + mocks.stopPcm.mockRejectedValueOnce(new Error('save failed')); + + await expect(recorder.stopRecording()).resolves.toBeNull(); + + expect(recorder.errorMessage.value).toBe('녹음 데이터를 저장하지 못했어요.'); + expect(stopTrack).toHaveBeenCalled(); + }); + + test('컴포넌트 해제 시 녹음 스트림과 분석기를 정리한다', async () => { + const { recorder, wrapper } = setup(); + await recorder.startRecording(); + + wrapper.unmount(); + + expect(recorder.isRecording.value).toBe(false); + expect(stopTrack).toHaveBeenCalled(); + expect(mocks.stopAnalysis).toHaveBeenCalled(); + expect(mocks.stopPcm).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/composables/checklist/useChecklistRecorder.test.js b/frontend/src/composables/checklist/useChecklistRecorder.test.js index f5b72f1..9c08d5c 100644 --- a/frontend/src/composables/checklist/useChecklistRecorder.test.js +++ b/frontend/src/composables/checklist/useChecklistRecorder.test.js @@ -98,4 +98,13 @@ describe('useChecklistRecorder', () => { expect(mocks.finish).toHaveBeenCalledWith(null); expect(mocks.save).not.toHaveBeenCalled(); }); + + test('녹음 시작 실패 시 생성된 서버 세션을 중단한다', async () => { + mocks.startRecording.mockRejectedValueOnce(new Error('start failed')); + const state = useChecklistRecorder(vi.fn(), ref(9)); + + await state.beginRecording(); + + expect(mocks.abort).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/composables/checklist/useLiveRecordingSession.test.js b/frontend/src/composables/checklist/useLiveRecordingSession.test.js index 7ca886c..5b152a7 100644 --- a/frontend/src/composables/checklist/useLiveRecordingSession.test.js +++ b/frontend/src/composables/checklist/useLiveRecordingSession.test.js @@ -7,6 +7,7 @@ import { useLiveRecordingSession } from './useLiveRecordingSession'; const mocks = vi.hoisted(() => ({ connect: vi.fn(), close: vi.fn(), send: vi.fn(), waitUntilSent: vi.fn(), getStatus: vi.fn(), start: vi.fn(), stop: vi.fn(), + socketErrorHandler: null, })); vi.mock('@/api/recording', () => ({ @@ -15,10 +16,13 @@ vi.mock('@/api/recording', () => ({ stopLiveRecording: mocks.stop, })); vi.mock('@/services/recordingSocket', () => ({ - createRecordingSocket: () => ({ - close: mocks.close, connect: mocks.connect, send: mocks.send, - waitUntilSent: mocks.waitUntilSent, - }), + createRecordingSocket: (onError) => { + mocks.socketErrorHandler = onError; + return { + close: mocks.close, connect: mocks.connect, send: mocks.send, + waitUntilSent: mocks.waitUntilSent, + }; + }, })); const setup = (onComplete = vi.fn()) => { @@ -35,6 +39,7 @@ const setup = (onComplete = vi.fn()) => { describe('useLiveRecordingSession', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.socketErrorHandler = null; mocks.start.mockResolvedValue({ recordingSessionId: 4, status: 'RECORDING' }); mocks.connect.mockResolvedValue(undefined); mocks.waitUntilSent.mockResolvedValue(undefined); @@ -97,4 +102,65 @@ describe('useLiveRecordingSession', () => { expect(state.isProcessing.value).toBe(false); expect(state.errorMessage.value).toBe('음성 없음'); }); + + test('WebSocket의 비동기 오류를 사용자에게 표시한다', () => { + const { state } = setup(); + + mocks.socketErrorHandler(new Error('소켓 전송 오류')); + + expect(state.errorMessage.value).toBe('소켓 전송 오류'); + }); + + test('분석 상태 조회 실패를 표시하고 중단 시 타이머와 세션을 정리한다', async () => { + mocks.getStatus.mockRejectedValue({ + response: { data: { message: '분석 상태를 확인할 수 없어요.' } }, + }); + const { state } = setup(); + await state.start(); + + await state.finish(new Blob(['wav'])); + await flushPromises(); + + expect(state.errorMessage.value).toBe('분석 상태를 확인할 수 없어요.'); + expect(state.isProcessing.value).toBe(true); + + await state.abort(); + expect(state.recordingSessionId.value).toBeNull(); + expect(state.isProcessing.value).toBe(false); + }); + + test('녹음 종료 요청 실패 시 연결을 닫고 오류를 다시 전달한다', async () => { + mocks.waitUntilSent.mockRejectedValue({ + response: { data: { message: '녹음을 종료할 수 없어요.' } }, + }); + const { state } = setup(); + await state.start(); + + await expect(state.finish(new Blob(['wav']))).rejects.toBeTruthy(); + + expect(mocks.close).toHaveBeenCalled(); + expect(state.isProcessing.value).toBe(false); + expect(state.errorMessage.value).toBe('녹음을 종료할 수 없어요.'); + }); + + test('녹음 중 중단하면 서버 세션도 종료한다', async () => { + const { state } = setup(); + await state.start(); + + await state.abort(); + + expect(mocks.stop).toHaveBeenCalledWith(4); + expect(state.status.value).toBeNull(); + }); + + test('컴포넌트 해제 시 진행 중인 녹음 리소스를 정리한다', async () => { + const { state, wrapper } = setup(); + await state.start(); + + wrapper.unmount(); + await flushPromises(); + + expect(mocks.close).toHaveBeenCalled(); + expect(mocks.stop).toHaveBeenCalledWith(4); + }); }); diff --git a/frontend/src/composables/checklist/useSavedRecording.test.js b/frontend/src/composables/checklist/useSavedRecording.test.js index 6b06358..8a0134f 100644 --- a/frontend/src/composables/checklist/useSavedRecording.test.js +++ b/frontend/src/composables/checklist/useSavedRecording.test.js @@ -155,4 +155,70 @@ describe('useSavedRecording', () => { expect(state.savedRecording.value).not.toBeNull(); expect(state.deleteErrorMessage.value).toBe('삭제할 수 없어요.'); }); + + test('기존 원격 녹음이 서버에서 사라지면 화면에서도 제거한다', async () => { + const { state } = setup(ref(7)); + await flushPromises(); + mocks.getFileUrl.mockRejectedValueOnce({ response: { status: 404 } }); + + await state.loadRemote(7); + + expect(state.savedRecording.value).toBeNull(); + }); + + test('원격 녹음 조회 실패 메시지를 표시한다', async () => { + mocks.getFileUrl.mockRejectedValue({ + response: { data: { message: '녹음을 불러올 수 없어요.' } }, + }); + const { state } = setup(ref(7)); + + await flushPromises(); + + expect(state.errorMessage.value).toBe('녹음을 불러올 수 없어요.'); + expect(state.isLoadingRecording.value).toBe(false); + }); + + test('URL 재발급 실패를 호출자와 화면에 전달한다', async () => { + const { state } = setup(ref(7)); + await flushPromises(); + state.savedRecording.value.expiresAt = 0; + mocks.getFileUrl.mockRejectedValueOnce({ + response: { data: { message: 'URL을 갱신할 수 없어요.' } }, + }); + + await expect(state.ensureFreshUrl()).rejects.toBeTruthy(); + + expect(state.errorMessage.value).toBe('URL을 갱신할 수 없어요.'); + }); + + test('녹음 텍스트 조회 실패를 모달에 표시한다', async () => { + mocks.getTranscript.mockRejectedValueOnce(new Error('transcript failed')); + const { state } = setup(); + state.save({ blob: new Blob(), duration: 1 }, { recordingSessionId: 7 }); + + await state.openTextModal(); + + expect(state.textErrorMessage.value).toBe('녹음 내용을 불러오지 못했어요.'); + expect(state.isTextLoading.value).toBe(false); + }); + + test('체크리스트에서 세션 ID가 제거되면 원격 녹음을 비운다', async () => { + const recordingSessionId = ref(7); + const { state } = setup(recordingSessionId); + await flushPromises(); + + recordingSessionId.value = null; + await nextTick(); + + expect(state.savedRecording.value).toBeNull(); + }); + + test('컴포넌트 해제 시 로컬 object URL을 폐기한다', () => { + const { state, wrapper } = setup(); + state.save({ blob: new Blob(), duration: 1 }, { recordingSessionId: 7 }); + + wrapper.unmount(); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:recording'); + }); }); diff --git a/frontend/src/composables/mypage/useMyPageDashboard.test.js b/frontend/src/composables/mypage/useMyPageDashboard.test.js new file mode 100644 index 0000000..ae7b02a --- /dev/null +++ b/frontend/src/composables/mypage/useMyPageDashboard.test.js @@ -0,0 +1,76 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useMyPageDashboard } from './useMyPageDashboard'; + +const mocks = vi.hoisted(() => ({ + authStore: { + characterType: 'CAT', + fetchMyPage: vi.fn(), + myPage: { email: 'user@example.com' }, + }, + getReports: vi.fn(), +})); + +vi.mock('@/api/report', () => ({ getReports: mocks.getReports })); +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); + +const setup = () => { + let state; + const wrapper = mount({ + setup() { + state = useMyPageDashboard(); + return () => null; + }, + }); + return { state, wrapper }; +}; + +describe('useMyPageDashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authStore.fetchMyPage.mockResolvedValue(undefined); + mocks.getReports.mockResolvedValue({ + totalCount: 2, + reports: [{ result: 'SAFE' }, { result: 'DANGER' }], + }); + }); + + test('계정과 리포트를 함께 불러와 활동을 집계한다', async () => { + const { state } = setup(); + + await flushPromises(); + + expect(state.activity.value).toEqual({ + total: 2, safe: 1, caution: 0, danger: 1, + }); + expect(state.activityLoading.value).toBe(false); + expect(state.secretaryLabel.value).toBeTruthy(); + }); + + test('계정과 리포트 조회 실패를 사용자 메시지로 표시한다', async () => { + mocks.authStore.fetchMyPage.mockRejectedValue({ + response: { data: { message: '계정 조회 실패' } }, + }); + mocks.getReports.mockRejectedValue({ + response: { data: { message: '리포트 조회 실패' } }, + }); + const { state } = setup(); + + await flushPromises(); + + expect(state.errorMessage.value).toBe('계정 조회 실패'); + expect(state.activityLoading.value).toBe(false); + }); + + test('계정 조회가 성공하고 리포트만 실패하면 리포트 오류를 표시한다', async () => { + mocks.getReports.mockRejectedValue({ + response: { data: { message: '리포트 조회 실패' } }, + }); + const { state } = setup(); + + await flushPromises(); + + expect(state.errorMessage.value).toBe('리포트 조회 실패'); + }); +}); diff --git a/frontend/src/composables/mypage/useProfileEditForm.test.js b/frontend/src/composables/mypage/useProfileEditForm.test.js new file mode 100644 index 0000000..1be93c0 --- /dev/null +++ b/frontend/src/composables/mypage/useProfileEditForm.test.js @@ -0,0 +1,117 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useProfileEditForm } from './useProfileEditForm'; + +const mocks = vi.hoisted(() => ({ + authStore: { + changePassword: vi.fn(), + fetchMyPage: vi.fn(), + myPage: null, + updateProfile: vi.fn(), + }, + replace: vi.fn(), +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('vue-router', () => ({ + useRouter: () => ({ replace: mocks.replace }), +})); + +const mountForm = () => + mount({ + setup: () => useProfileEditForm(), + template: '
', + }); + +describe('useProfileEditForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authStore.myPage = null; + mocks.authStore.fetchMyPage.mockResolvedValue({ + email: 'user@example.com', + nickname: '기존닉네임', + }); + mocks.authStore.updateProfile.mockResolvedValue({}); + mocks.authStore.changePassword.mockResolvedValue({}); + }); + + it('마운트 시 회원정보를 조회해 닉네임을 초기화한다', async () => { + const wrapper = mountForm(); + await flushPromises(); + + expect(mocks.authStore.fetchMyPage).toHaveBeenCalledOnce(); + expect(wrapper.vm.nickname).toBe('기존닉네임'); + }); + + it('스토어에 회원정보가 있으면 재조회하지 않는다', async () => { + mocks.authStore.myPage = { + email: 'saved@example.com', + nickname: '저장닉네임', + }; + + const wrapper = mountForm(); + await flushPromises(); + + expect(mocks.authStore.fetchMyPage).not.toHaveBeenCalled(); + expect(wrapper.vm.email).toBe('saved@example.com'); + expect(wrapper.vm.nickname).toBe('저장닉네임'); + }); + + it('닉네임과 비밀번호 입력을 단계별로 검증한다', async () => { + const wrapper = mountForm(); + await flushPromises(); + + wrapper.vm.nickname = '한'; + await wrapper.vm.saveAccount(); + expect(wrapper.vm.formMessage).toContain('닉네임은'); + + wrapper.vm.nickname = '새닉네임'; + await wrapper.vm.saveAccount(); + expect(wrapper.vm.formMessage).toContain('현재 비밀번호'); + + wrapper.vm.passwords.currentPassword = 'password1!'; + wrapper.vm.passwords.newPassword = 'invalid'; + await wrapper.vm.saveAccount(); + expect(wrapper.vm.formMessage).toContain('영문, 숫자, 특수문자'); + expect(mocks.authStore.updateProfile).not.toHaveBeenCalled(); + }); + + it('프로필과 비밀번호를 순서대로 변경한 뒤 로그인으로 이동한다', async () => { + const wrapper = mountForm(); + await flushPromises(); + wrapper.vm.nickname = ' 새닉네임 '; + wrapper.vm.passwords.currentPassword = 'password1!'; + wrapper.vm.passwords.newPassword = 'newPassword1!'; + + await wrapper.vm.saveAccount(); + + expect(mocks.authStore.updateProfile).toHaveBeenCalledWith('새닉네임'); + expect(mocks.authStore.changePassword).toHaveBeenCalledWith({ + currentPassword: 'password1!', + newPassword: 'newPassword1!', + newPasswordConfirm: 'newPassword1!', + }); + expect(mocks.replace).toHaveBeenCalledWith({ + name: 'login', + query: { accountUpdated: 'true' }, + }); + expect(wrapper.vm.submitting).toBe(false); + }); + + it('저장 실패 메시지를 표시하고 submitting을 복원한다', async () => { + mocks.authStore.updateProfile.mockRejectedValue({ + response: { data: { message: '변경 실패' } }, + }); + const wrapper = mountForm(); + await flushPromises(); + wrapper.vm.nickname = '새닉네임'; + wrapper.vm.passwords.currentPassword = 'password1!'; + wrapper.vm.passwords.newPassword = 'newPassword1!'; + + await wrapper.vm.saveAccount(); + + expect(wrapper.vm.formMessage).toBe('변경 실패'); + expect(wrapper.vm.submitting).toBe(false); + }); +}); diff --git a/frontend/src/composables/mypage/useSecretarySelection.test.js b/frontend/src/composables/mypage/useSecretarySelection.test.js new file mode 100644 index 0000000..df53c99 --- /dev/null +++ b/frontend/src/composables/mypage/useSecretarySelection.test.js @@ -0,0 +1,102 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSecretarySelection } from './useSecretarySelection'; + +const mocks = vi.hoisted(() => ({ + authStore: { + changeCharacter: vi.fn(), + characterType: 'CAT', + fetchMyPage: vi.fn(), + isAuthenticated: true, + myPage: { characterType: 'CAT' }, + }, +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); + +const mountSelection = () => + mount({ + setup: () => useSecretarySelection(), + template: '
', + }); + +describe('useSecretarySelection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.authStore.characterType = 'CAT'; + mocks.authStore.isAuthenticated = true; + mocks.authStore.myPage = { characterType: 'CAT' }; + mocks.authStore.fetchMyPage.mockResolvedValue({ characterType: 'WOMAN' }); + mocks.authStore.changeCharacter.mockImplementation(async (characterType) => { + mocks.authStore.myPage = { characterType }; + }); + }); + + afterEach(() => vi.useRealTimers()); + + it('인증 상태와 저장된 프로필에 따라 비서를 초기화한다', async () => { + mocks.authStore.myPage = null; + const wrapper = mountSelection(); + await flushPromises(); + + expect(mocks.authStore.fetchMyPage).toHaveBeenCalledOnce(); + expect(wrapper.vm.selectedCharacter).toBe('WOMAN'); + }); + + it('비로그인은 회원정보를 조회하지 않는다', async () => { + mocks.authStore.isAuthenticated = false; + mocks.authStore.myPage = null; + mountSelection(); + await flushPromises(); + + expect(mocks.authStore.fetchMyPage).not.toHaveBeenCalled(); + }); + + it('이미 선택된 비서는 API 요청 없이 안내 메시지를 표시한다', async () => { + const wrapper = mountSelection(); + await flushPromises(); + + await wrapper.vm.selectSecretary('CAT'); + + expect(mocks.authStore.changeCharacter).not.toHaveBeenCalled(); + expect(wrapper.vm.message).toContain('냥냥이를 비서로'); + }); + + it('선택한 비서를 저장하고 바운스 애니메이션을 종료한다', async () => { + const wrapper = mountSelection(); + await flushPromises(); + + await wrapper.vm.selectSecretary('MAN'); + expect(mocks.authStore.changeCharacter).toHaveBeenCalledWith('MAN'); + expect(wrapper.vm.selectedCharacter).toBe('MAN'); + expect(wrapper.vm.message).toContain('위장남사친을 비서로'); + + await vi.runAllTimersAsync(); + expect(wrapper.vm.animatingCharacter).toBeNull(); + }); + + it('저장 실패 시 이전 선택으로 되돌린다', async () => { + mocks.authStore.changeCharacter.mockRejectedValue({ + response: { data: { message: '저장 실패' } }, + }); + const wrapper = mountSelection(); + await flushPromises(); + + await wrapper.vm.selectSecretary('WOMAN'); + + expect(wrapper.vm.selectedCharacter).toBe('CAT'); + expect(wrapper.vm.message).toBe('저장 실패'); + expect(wrapper.vm.saving).toBe(false); + }); + + it('준비 중 카드 메시지를 표시한다', async () => { + const wrapper = mountSelection(); + await flushPromises(); + + wrapper.vm.showPreparingMessage(); + + expect(wrapper.vm.message).toContain('준비 중'); + }); +}); diff --git a/frontend/src/composables/useBootstrapModal.test.js b/frontend/src/composables/useBootstrapModal.test.js new file mode 100644 index 0000000..6ae9398 --- /dev/null +++ b/frontend/src/composables/useBootstrapModal.test.js @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { nextTick, ref } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Modal } from 'bootstrap'; +import { useBootstrapModal } from './useBootstrapModal'; + +const mocks = vi.hoisted(() => ({ + modal: { + dispose: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + }, +})); + +vi.mock('bootstrap', () => ({ + Modal: vi.fn(function ModalMock() { + return mocks.modal; + }), +})); + +const mountModal = (initialOpen = false, onClose = vi.fn()) => { + const open = ref(initialOpen); + const wrapper = mount({ + setup() { + return { open, ...useBootstrapModal(open, onClose) }; + }, + template: '
', + }); + + return { onClose, open, wrapper }; +}; + +describe('useBootstrapModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it('마운트 시 Bootstrap Modal을 생성하고 열린 상태를 반영한다', () => { + const { wrapper } = mountModal(true); + + expect(Modal).toHaveBeenCalledWith(wrapper.element); + expect(mocks.modal.show).toHaveBeenCalledOnce(); + }); + + it('open 변경에 따라 모달을 열고 닫는다', async () => { + const { open } = mountModal(); + + open.value = true; + await nextTick(); + expect(mocks.modal.show).toHaveBeenCalledOnce(); + + open.value = false; + await nextTick(); + expect(mocks.modal.hide).toHaveBeenCalledOnce(); + }); + + it('숨김 이벤트와 hide 함수를 연결하고 언마운트 시 해제한다', () => { + const { onClose, wrapper } = mountModal(); + + wrapper.element.dispatchEvent(new Event('hidden.bs.modal')); + wrapper.vm.hide(); + + expect(onClose).toHaveBeenCalledOnce(); + expect(mocks.modal.hide).toHaveBeenCalledOnce(); + + wrapper.unmount(); + expect(mocks.modal.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/constants/report/mock.js b/frontend/src/constants/report/mock.js index a8cab21..ba0e7f4 100644 --- a/frontend/src/constants/report/mock.js +++ b/frontend/src/constants/report/mock.js @@ -11,6 +11,7 @@ export const MOCK_REPORT_DETAIL = { detailAddress: '101동 101호', deposit: 100000000, result: 'SAFE', + ratio: 0.5533, favorite: true, checkResults: [ // 필수 점검 1: 근저당 설정 금액 @@ -103,6 +104,7 @@ export const SCENARIO_B_REPORT_DETAIL = { detailAddress: '101호', deposit: 170_000_000, result: 'DANGER', + ratio: 0.85, checkResults: MOCK_REPORT_DETAIL.checkResults.map((check) => check.checkType === 'HUG_GUARANTEE_ELIGIBILITY' ? { @@ -140,6 +142,7 @@ export const SCENARIO_C_REPORT_DETAIL = { detailAddress: '101호', deposit: 100_000_000, result: 'DANGER', + ratio: 0.4, checkResults: MOCK_REPORT_DETAIL.checkResults.map((check) => check.checkType === 'HUG_GUARANTEE_ELIGIBILITY' ? { @@ -175,6 +178,7 @@ export const SCENARIO_D_REPORT_DETAIL = { detailAddress: '101호', deposit: 80_000_000, result: 'DANGER', + ratio: 0.32, checkResults: MOCK_REPORT_DETAIL.checkResults.map((check) => { if (check.checkType === 'MORTGAGE_EXISTENCE') { return { @@ -226,6 +230,7 @@ export const SCENARIO_E_REPORT_DETAIL = { detailAddress: '101호', deposit: 100_000_000, result: 'DANGER', + ratio: null, checkResults: MOCK_REPORT_DETAIL.checkResults.map((check) => { if (check.checkType === 'BUILDING_USE') { return { @@ -287,6 +292,7 @@ export const SCENARIO_F_REPORT_DETAIL = { detailAddress: '101호', deposit: 100_000_000, result: 'DANGER', + ratio: 0.5, // TODO: AI/ChatGPT API 연결 후 aiSpecialTerms를 실제 추천 특약 응답으로 교체합니다. aiSpecialTerms: [ { diff --git a/frontend/src/main.test.js b/frontend/src/main.test.js new file mode 100644 index 0000000..2b2b4ca --- /dev/null +++ b/frontend/src/main.test.js @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + app: { mount: vi.fn(), use: vi.fn() }, + authStore: { clearAuth: vi.fn() }, + createApp: vi.fn(), + h: vi.fn(), + pinia: { id: 'pinia' }, + router: { + currentRoute: { value: { name: 'main' } }, + replace: vi.fn(), + }, +})); + +vi.mock('vue', () => ({ + createApp: mocks.createApp, + h: mocks.h, +})); +vi.mock('pinia', () => ({ createPinia: vi.fn(() => mocks.pinia) })); +vi.mock('vue-router', () => ({ RouterView: { name: 'RouterView' } })); +vi.mock('./api/instance', () => ({ + AUTH_UNAUTHORIZED_EVENT: 'auth:unauthorized', +})); +vi.mock('./router', () => ({ default: mocks.router })); +vi.mock('./stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('bootstrap', () => ({})); + +describe('앱 진입점', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.createApp.mockReturnValue(mocks.app); + mocks.router.currentRoute.value = { name: 'main' }; + await import('./main'); + }); + + it('Pinia와 라우터를 등록하고 앱을 마운트한다', () => { + expect(mocks.createApp).toHaveBeenCalledOnce(); + expect(mocks.app.use).toHaveBeenNthCalledWith(1, mocks.pinia); + expect(mocks.app.use).toHaveBeenNthCalledWith(2, mocks.router); + expect(mocks.app.mount).toHaveBeenCalledWith('#app'); + + const rootComponent = mocks.createApp.mock.calls[0][0]; + rootComponent.render(); + expect(mocks.h).toHaveBeenCalled(); + }); + + it('인증 만료 이벤트를 받으면 인증을 지우고 로그인으로 이동한다', () => { + window.dispatchEvent(new CustomEvent('auth:unauthorized')); + + expect(mocks.authStore.clearAuth).toHaveBeenCalled(); + expect(mocks.router.replace).toHaveBeenCalledWith({ name: 'login' }); + }); + + it('이미 로그인 화면이면 중복 이동하지 않는다', () => { + mocks.router.currentRoute.value = { name: 'login' }; + + window.dispatchEvent(new CustomEvent('auth:unauthorized')); + + expect(mocks.authStore.clearAuth).toHaveBeenCalled(); + expect(mocks.router.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/router/reportRoutes.test.js b/frontend/src/router/reportRoutes.test.js index 337b017..bcacc85 100644 --- a/frontend/src/router/reportRoutes.test.js +++ b/frontend/src/router/reportRoutes.test.js @@ -33,4 +33,34 @@ describe('report routes', () => { expect(shared.meta.analysisShared).toBe(true); expect(shared.meta.requiresAuth).toBeUndefined(); }); + + test('비로그인 사용자는 인증 필수 화면에서 로그인으로 이동한다', async () => { + await router.push('/mypage'); + + expect(router.currentRoute.value.name).toBe('login'); + expect(router.currentRoute.value.query.redirect).toBe('/mypage'); + }); + + test('모든 지연 로딩 화면 모듈을 정상적으로 불러온다', async () => { + const loaders = router + .getRoutes() + .map((route) => route.components?.default) + .filter((component) => typeof component === 'function'); + + const modules = await Promise.all(loaders.map((load) => load())); + + expect(loaders.length).toBeGreaterThan(15); + expect(modules.every((module) => module.default)).toBe(true); + }); + + test('이전 분석 상세 URL의 ID를 현재 라우트로 전달한다', () => { + const legacyRoute = router.getRoutes().find( + ({ path }) => path === '/analysis/:analysisReportId(\\d+)', + ); + + expect(legacyRoute.redirect({ params: { analysisReportId: '27' } })).toEqual({ + name: 'analysis', + params: { analysisReportId: '27' }, + }); + }); }); diff --git a/frontend/src/services/recordingSocket.test.js b/frontend/src/services/recordingSocket.test.js index e6eb18e..0be6762 100644 --- a/frontend/src/services/recordingSocket.test.js +++ b/frontend/src/services/recordingSocket.test.js @@ -73,4 +73,33 @@ describe('recordingSocket', () => { currentSocket().dispatchEvent(new Event('close')); expect(onError).toHaveBeenCalledTimes(1); }); + + test('연결 오류를 콜백과 Promise 양쪽에 전달한다', async () => { + const onError = vi.fn(); + const client = createRecordingSocket(onError); + const connecting = client.connect(1); + + currentSocket().dispatchEvent(new Event('error')); + + await expect(connecting).rejects.toThrow('녹음 서버에 연결하지 못했어요.'); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ + message: '녹음 서버에 연결하지 못했어요.', + })); + }); + + test('WebSocket 버퍼가 비워질 때까지 전송 완료를 기다린다', async () => { + vi.useFakeTimers(); + const client = createRecordingSocket(); + const connecting = client.connect(1); + currentSocket().dispatchEvent(new Event('open')); + await connecting; + currentSocket().bufferedAmount = 8; + + const waiting = client.waitUntilSent(); + currentSocket().bufferedAmount = 0; + await vi.advanceTimersByTimeAsync(30); + + await expect(waiting).resolves.toBeUndefined(); + vi.useRealTimers(); + }); }); diff --git a/frontend/src/stores/counter.test.js b/frontend/src/stores/counter.test.js new file mode 100644 index 0000000..c64a07a --- /dev/null +++ b/frontend/src/stores/counter.test.js @@ -0,0 +1,17 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useCounterStore } from './counter'; + +describe('counter store', () => { + beforeEach(() => setActivePinia(createPinia())); + + it('카운트를 증가시키고 두 배 값을 계산한다', () => { + const store = useCounterStore(); + + store.increment(); + + expect(store.count).toBe(1); + expect(store.doubleCount).toBe(2); + }); +}); diff --git a/frontend/src/utils/authValidator.spec.js b/frontend/src/utils/authValidator.test.js similarity index 100% rename from frontend/src/utils/authValidator.spec.js rename to frontend/src/utils/authValidator.test.js diff --git a/frontend/src/utils/dictionary/dictionary.test.js b/frontend/src/utils/dictionary/dictionary.test.js index 87b72ec..929ac39 100644 --- a/frontend/src/utils/dictionary/dictionary.test.js +++ b/frontend/src/utils/dictionary/dictionary.test.js @@ -1,11 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { normalizeFraudTypes } from './fraud'; import { normalizeDictionaryCharacter, resolveDictionaryCharacter, } from './characters'; -import { findLastContentRow, normalizeGuideConfig } from './guides'; +import { + findImageContentRatio, + findLastContentRow, + normalizeGuideConfig, +} from './guides'; import { normalizeDictionaryCards } from './main'; import { normalizeWordItems } from './words'; @@ -74,6 +78,8 @@ describe('dictionary data normalization', () => { }); describe('comic image trimming', () => { + afterEach(() => vi.restoreAllMocks()); + it('returns the last row containing enough non-white pixels', () => { const width = 4; const height = 4; @@ -96,4 +102,31 @@ describe('comic image trimming', () => { expect(findLastContentRow(pixels, width, height)).toBe(height - 1); }); + + it('캔버스 픽셀을 분석해 이미지 콘텐츠 비율을 캐시한다', () => { + const pixels = new Uint8ClampedArray(100 * 200 * 4).fill(255); + for (let x = 0; x < 10; x += 1) { + const offset = (120 * 100 + x) * 4; + pixels[offset] = 0; + } + const context = { + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: pixels })), + }; + const canvas = { getContext: vi.fn(() => context), height: 0, width: 0 }; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + const image = { + naturalHeight: 200, + naturalWidth: 100, + src: '/comic-content-ratio.png', + }; + + const firstRatio = findImageContentRatio(image); + const cachedRatio = findImageContentRatio(image); + + expect(firstRatio).toBeGreaterThan(0.5); + expect(firstRatio).toBeLessThan(1); + expect(cachedRatio).toBe(firstRatio); + expect(context.drawImage).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/utils/report/mapper.js b/frontend/src/utils/report/mapper.js index 5398d10..191ced2 100644 --- a/frontend/src/utils/report/mapper.js +++ b/frontend/src/utils/report/mapper.js @@ -49,6 +49,17 @@ const hasValue = (value) => value !== null && value !== undefined; const formatWon = (value) => hasValue(value) ? formatKoreanDeposit(Number(value) || 0) : '-'; +// API의 소수 비율(0.5556)을 화면에 표시할 백분율(55.56%)로 변환합니다. +export const formatRatioPercent = (ratio) => { + if (!hasValue(ratio) || ratio === '') return '-'; + + const numericRatio = Number(ratio); + + if (!Number.isFinite(numericRatio) || numericRatio < 0) return '-'; + + return `${Number((numericRatio * 100).toFixed(2))}%`; +}; + const makeCheckCopy = (checkType, evidence = {}, status) => { switch (checkType) { case 'MORTGAGE_EXISTENCE': @@ -153,6 +164,7 @@ export const mapReportDetail = (report = {}) => ({ [report.roadAddress, report.detailAddress].filter(Boolean).join(' ') || '-', deposit: hasValue(report.deposit) ? String(report.deposit) : '-', risk: toUiRisk(report.result), + ratio: formatRatioPercent(report.ratio), favorite: Boolean(report.favorite), secretary: mapSecretary(report.secretary ?? report.characterType), checks: mapCheckResults(report.checkResults), diff --git a/frontend/src/utils/report/mapper.test.js b/frontend/src/utils/report/mapper.test.js index d589cc1..53145d8 100644 --- a/frontend/src/utils/report/mapper.test.js +++ b/frontend/src/utils/report/mapper.test.js @@ -8,6 +8,7 @@ import { mapReportDetail, mapSecretary, mapSpecialTerms, + formatRatioPercent, toUiRisk, } from './mapper.js'; import { @@ -24,6 +25,13 @@ test('백엔드 위험도 enum을 화면 상태값으로 변환한다', () => { assert.equal(toUiRisk(null), 'caution'); }); +test('API 전세가율을 백분율 표시값으로 변환한다', () => { + assert.equal(formatRatioPercent(0.5556), '55.56%'); + assert.equal(formatRatioPercent(0.7), '70%'); + assert.equal(formatRatioPercent(0), '0%'); + assert.equal(formatRatioPercent(null), '-'); +}); + test('필수 점검 응답을 화면 데이터로 변환한다', () => { const [check] = mapCheckResults([ { @@ -60,6 +68,7 @@ test('리포트 상세 응답의 기본 필드를 화면 상태로 묶는다', ( detailAddress: '101동', deposit: 100_000_000, result: 'SAFE', + ratio: 0.5556, favorite: true, checkResults: [], fraudTypes: [], @@ -68,6 +77,7 @@ test('리포트 상세 응답의 기본 필드를 화면 상태로 묶는다', ( assert.equal(report.address, '서울시 마포구 101동'); assert.equal(report.deposit, '100000000'); assert.equal(report.risk, 'safe'); + assert.equal(report.ratio, '55.56%'); assert.equal(report.favorite, true); }); @@ -141,6 +151,7 @@ test('null 근거값은 하이픈으로, null 판정은 주의로 변환한다', assert.equal(report.address, '-'); assert.equal(report.risk, 'caution'); + assert.equal(report.ratio, '-'); assert.equal(report.checks[0].status, 'caution'); assert.equal(report.checks[0].amount, '-'); assert.equal(report.checks[1].status, 'caution'); @@ -160,3 +171,22 @@ test('시나리오별 비서 설정을 화면 캐릭터 타입으로 변환한 assert.equal(mapSecretary(ANALYSIS_PREVIEW_REPORTS.d.secretary), 'woman'); assert.equal(mapSecretary('UNKNOWN'), null); }); + +test('알 수 없는 점검·사기 유형도 API enum을 그대로 표시한다', () => { + const [check] = mapCheckResults([ + { checkType: 'NEW_CHECK', result: 'SAFE', evidence: {} }, + ]); + const [fraudType] = mapFraudTypes([ + { fraudType: 'NEW_FRAUD', riskLevel: 'CAUTION' }, + ]); + + assert.deepEqual( + { id: check.id, label: check.label, basis: check.basis }, + { id: 'NEW_CHECK', label: 'NEW_CHECK', basis: '판정 근거 확인 필요' }, + ); + assert.deepEqual( + { id: fraudType.id, title: fraudType.title, subtitle: fraudType.subtitle }, + { id: 'NEW_FRAUD', title: 'NEW_FRAUD', subtitle: '세부 판정 결과' }, + ); + assert.deepEqual(fraudType.items, []); +}); diff --git a/frontend/src/views/MyPageView.test.js b/frontend/src/views/MyPageView.test.js index 0bdc067..fd81838 100644 --- a/frontend/src/views/MyPageView.test.js +++ b/frontend/src/views/MyPageView.test.js @@ -29,6 +29,8 @@ vi.mock('vue-router', () => ({ describe('MyPageView', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.authStore.isAuthenticated = true; + mocks.authStore.logout.mockResolvedValue(undefined); mocks.authStore.fetchMyPage.mockResolvedValue(mocks.authStore.myPage); mocks.authStore.characterType = 'CAT'; mocks.getReports.mockResolvedValue({ @@ -83,4 +85,45 @@ describe('MyPageView', () => { expect(wrapper.text()).toContain('엘리스'); expect(wrapper.text()).toContain('현재 AI 비서'); }); + + it('로그인 사용자를 로그아웃하고 로그인 화면으로 이동한다', async () => { + const wrapper = mount(MyPageView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + await flushPromises(); + + await wrapper.get('.mypage__menu button').trigger('click'); + await flushPromises(); + + expect(mocks.authStore.logout).toHaveBeenCalledOnce(); + expect(mocks.replace).toHaveBeenCalledWith({ name: 'login' }); + }); + + it('비로그인 상태에서는 서버 로그아웃 없이 로그인으로 이동한다', async () => { + mocks.authStore.isAuthenticated = false; + const wrapper = mount(MyPageView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + await flushPromises(); + + await wrapper.get('.mypage__menu button').trigger('click'); + + expect(mocks.authStore.logout).not.toHaveBeenCalled(); + expect(mocks.replace).toHaveBeenCalledWith({ name: 'login' }); + }); + + it('로그아웃 실패 메시지를 표시한다', async () => { + mocks.authStore.logout.mockRejectedValue({ + response: { data: { message: '로그아웃 실패' } }, + }); + const wrapper = mount(MyPageView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + await flushPromises(); + + await wrapper.get('.mypage__menu button').trigger('click'); + await flushPromises(); + + expect(wrapper.get('[role="alert"]').text()).toBe('로그아웃 실패'); + }); }); diff --git a/frontend/src/views/auth/SignupView.test.js b/frontend/src/views/auth/SignupView.test.js index e1c5f3f..1955abb 100644 --- a/frontend/src/views/auth/SignupView.test.js +++ b/frontend/src/views/auth/SignupView.test.js @@ -233,4 +233,29 @@ describe('SignupView 회원가입 화면', () => { expect(wrapper.get('.error-message').text()).toBe('이미 사용 중인 이메일입니다.'); expect(mocks.replace).not.toHaveBeenCalled(); }); + + it('약관 조회 실패 메시지를 표시한다', async () => { + mocks.getLatestTerms.mockRejectedValueOnce({ + response: { data: { message: '약관을 불러올 수 없습니다.' } }, + }); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.get('.error-message').text()).toBe('약관을 불러올 수 없습니다.'); + expect(wrapper.findAll('.term-row')).toHaveLength(0); + }); + + it('입력값이 유효해도 필수 약관에 동의하지 않으면 제출하지 않는다', async () => { + const wrapper = mountView(); + await flushPromises(); + await fillValidForm(wrapper); + + await wrapper.get('form').trigger('submit'); + + expect(mocks.authStore.signup).not.toHaveBeenCalled(); + expect(wrapper.get('.error-message').text()).toBe( + '필수 약관을 모두 확인하고 동의해 주세요.', + ); + }); }); diff --git a/frontend/src/views/dictionary/DictionaryCharacterView.spec.js b/frontend/src/views/dictionary/DictionaryCharacterView.test.js similarity index 100% rename from frontend/src/views/dictionary/DictionaryCharacterView.spec.js rename to frontend/src/views/dictionary/DictionaryCharacterView.test.js diff --git a/frontend/src/views/dictionary/DictionaryGuideView.test.js b/frontend/src/views/dictionary/DictionaryGuideView.test.js new file mode 100644 index 0000000..6ca98cf --- /dev/null +++ b/frontend/src/views/dictionary/DictionaryGuideView.test.js @@ -0,0 +1,46 @@ +import { mount, RouterLinkStub } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import DictionaryGuideView from './DictionaryGuideView.vue'; + +const mocks = vi.hoisted(() => ({ + authStore: { + isAuthenticated: false, + myPage: null, + fetchMyPage: vi.fn(), + }, + route: { meta: { guideType: 'register' } }, +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('vue-router', async (importOriginal) => ({ + ...(await importOriginal()), + useRoute: () => mocks.route, +})); + +const mountView = () => + mount(DictionaryGuideView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + +describe('DictionaryGuideView', () => { + beforeEach(() => { + mocks.route.meta = { guideType: 'register' }; + }); + + it('등기부등본 가이드의 첫 탭을 표시한다', () => { + const wrapper = mountView(); + + expect(wrapper.get('.guide-header').text()).toContain('등기부등본'); + expect(wrapper.findAll('[role="tab"]').length).toBeGreaterThan(0); + expect(wrapper.get('[role="tab"]').attributes('aria-selected')).toBe('true'); + }); + + it('전입신고 가이드 설정을 선택한다', () => { + mocks.route.meta = { guideType: 'moveIn' }; + + const wrapper = mountView(); + + expect(wrapper.get('.guide-header').text()).toContain('전입신고'); + }); +}); diff --git a/frontend/src/views/dictionary/FraudDictionaryView.test.js b/frontend/src/views/dictionary/FraudDictionaryView.test.js new file mode 100644 index 0000000..02e978a --- /dev/null +++ b/frontend/src/views/dictionary/FraudDictionaryView.test.js @@ -0,0 +1,40 @@ +import { mount, RouterLinkStub } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import FraudDictionaryView from './FraudDictionaryView.vue'; + +const mocks = vi.hoisted(() => ({ + authStore: { + isAuthenticated: false, + myPage: null, + fetchMyPage: vi.fn(), + }, + push: vi.fn(), +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('vue-router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: mocks.push }), +})); + +describe('FraudDictionaryView', () => { + beforeEach(() => vi.clearAllMocks()); + + it('사기 유형을 표시하고 선택한 영상으로 이동한다', async () => { + const wrapper = mount(FraudDictionaryView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + const firstCard = wrapper.get('.fraud-card'); + + expect(wrapper.findAll('.fraud-card')).toHaveLength(3); + expect(firstCard.text()).toContain('무자본'); + + await firstCard.get('button').trigger('click'); + + expect(mocks.push).toHaveBeenCalledWith({ + name: 'dictionary-fraud-video', + params: { typeId: 'gap-investment' }, + }); + }); +}); diff --git a/frontend/src/views/dictionary/WordDictionaryView.test.js b/frontend/src/views/dictionary/WordDictionaryView.test.js new file mode 100644 index 0000000..612f0d0 --- /dev/null +++ b/frontend/src/views/dictionary/WordDictionaryView.test.js @@ -0,0 +1,25 @@ +import { mount, RouterLinkStub } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import WordDictionaryView from './WordDictionaryView.vue'; + +const authStore = vi.hoisted(() => ({ + isAuthenticated: false, + myPage: null, + fetchMyPage: vi.fn(), +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => authStore })); + +describe('WordDictionaryView', () => { + it('전세 용어 목록과 비서 안내를 표시한다', () => { + const wrapper = mount(WordDictionaryView, { + global: { stubs: { RouterLink: RouterLinkStub } }, + }); + + expect(wrapper.get('.word-header').text()).toContain('전세사기 도감'); + expect(wrapper.findAll('.word-item').length).toBeGreaterThan(5); + expect(wrapper.text()).toContain('대항력'); + expect(wrapper.get('.secretary-guide').exists()).toBe(true); + }); +}); diff --git a/frontend/src/views/mypage/ProfileEditView.test.js b/frontend/src/views/mypage/ProfileEditView.test.js new file mode 100644 index 0000000..a94d503 --- /dev/null +++ b/frontend/src/views/mypage/ProfileEditView.test.js @@ -0,0 +1,61 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ProfileEditView from './ProfileEditView.vue'; + +const mocks = vi.hoisted(() => ({ + authStore: { + changePassword: vi.fn(), + fetchMyPage: vi.fn(), + myPage: { + email: 'user@example.com', + nickname: '기존닉네임', + }, + updateProfile: vi.fn(), + }, + replace: vi.fn(), +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('vue-router', () => ({ + useRouter: () => ({ replace: mocks.replace }), +})); + +describe('ProfileEditView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authStore.updateProfile.mockResolvedValue({}); + mocks.authStore.changePassword.mockResolvedValue({}); + }); + + it('현재 회원정보를 표시하고 유효한 폼을 저장한다', async () => { + const wrapper = mount(ProfileEditView); + await flushPromises(); + + expect(wrapper.text()).toContain('user@example.com'); + expect(wrapper.get('#nickname').element.value).toBe('기존닉네임'); + + await wrapper.get('#nickname').setValue('새닉네임'); + await wrapper.get('#current-password').setValue('password1!'); + await wrapper.get('#new-password').setValue('newPassword1!'); + await wrapper.get('form').trigger('submit'); + await flushPromises(); + + expect(mocks.authStore.updateProfile).toHaveBeenCalledWith('새닉네임'); + expect(mocks.authStore.changePassword).toHaveBeenCalled(); + expect(mocks.replace).toHaveBeenCalledWith({ + name: 'login', + query: { accountUpdated: 'true' }, + }); + }); + + it('유효하지 않은 폼의 안내 메시지를 표시한다', async () => { + const wrapper = mount(ProfileEditView); + await flushPromises(); + + await wrapper.get('#nickname').setValue('한'); + await wrapper.get('form').trigger('submit'); + + expect(wrapper.get('[role="alert"]').text()).toContain('닉네임'); + }); +}); diff --git a/frontend/src/views/mypage/SecretaryChangeView.test.js b/frontend/src/views/mypage/SecretaryChangeView.test.js new file mode 100644 index 0000000..4e68f15 --- /dev/null +++ b/frontend/src/views/mypage/SecretaryChangeView.test.js @@ -0,0 +1,48 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SecretaryChangeView from './SecretaryChangeView.vue'; + +const mocks = vi.hoisted(() => ({ + authStore: { + changeCharacter: vi.fn(), + characterType: 'CAT', + fetchMyPage: vi.fn(), + isAuthenticated: true, + myPage: { characterType: 'CAT' }, + }, +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); + +describe('SecretaryChangeView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authStore.characterType = 'CAT'; + mocks.authStore.myPage = { characterType: 'CAT' }; + mocks.authStore.changeCharacter.mockResolvedValue({ characterType: 'MAN' }); + }); + + it('비서 선택지를 표시하고 선택을 저장한다', async () => { + const wrapper = mount(SecretaryChangeView); + await flushPromises(); + const cards = wrapper.findAll('.secretary-card'); + + expect(cards).toHaveLength(4); + expect(cards[0].classes()).toContain('selected'); + + await cards[2].trigger('click'); + await flushPromises(); + + expect(mocks.authStore.changeCharacter).toHaveBeenCalledWith('MAN'); + expect(wrapper.get('[role="status"]').text()).toContain('위장남사친'); + }); + + it('준비 중 비서를 누르면 안내한다', async () => { + const wrapper = mount(SecretaryChangeView); + + await wrapper.findAll('.secretary-card')[3].trigger('click'); + + expect(wrapper.get('[role="status"]').text()).toContain('준비 중'); + }); +}); diff --git a/frontend/src/views/mypage/WithdrawView.test.js b/frontend/src/views/mypage/WithdrawView.test.js new file mode 100644 index 0000000..bf7edfc --- /dev/null +++ b/frontend/src/views/mypage/WithdrawView.test.js @@ -0,0 +1,71 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import WithdrawView from './WithdrawView.vue'; + +const mocks = vi.hoisted(() => ({ + authStore: { withdraw: vi.fn() }, + back: vi.fn(), + replace: vi.fn(), +})); + +vi.mock('@/stores/auth', () => ({ useAuthStore: () => mocks.authStore })); +vi.mock('vue-router', () => ({ + useRouter: () => ({ replace: mocks.replace }), +})); + +const mountView = () => + mount(WithdrawView, { + global: { mocks: { $router: { back: mocks.back } } }, + }); + +describe('WithdrawView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authStore.withdraw.mockResolvedValue({}); + }); + + it('비밀번호와 동의가 없으면 탈퇴를 요청하지 않는다', async () => { + const wrapper = mountView(); + + await wrapper.get('form').trigger('submit'); + + expect(wrapper.get('[role="alert"]').text()).toContain('탈퇴 동의'); + expect(mocks.authStore.withdraw).not.toHaveBeenCalled(); + }); + + it('탈퇴 성공 후 메인으로 이동한다', async () => { + const wrapper = mountView(); + await wrapper.get('input[type="password"]').setValue('password1!'); + await wrapper.get('input[type="checkbox"]').setValue(true); + + await wrapper.get('form').trigger('submit'); + await flushPromises(); + + expect(mocks.authStore.withdraw).toHaveBeenCalledWith('password1!'); + expect(mocks.replace).toHaveBeenCalledWith({ name: 'main' }); + }); + + it('탈퇴 실패 메시지를 표시한다', async () => { + mocks.authStore.withdraw.mockRejectedValue({ + response: { data: { message: '비밀번호 불일치' } }, + }); + const wrapper = mountView(); + await wrapper.get('input[type="password"]').setValue('wrong'); + await wrapper.get('input[type="checkbox"]').setValue(true); + + await wrapper.get('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.get('[role="alert"]').text()).toBe('비밀번호 불일치'); + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeUndefined(); + }); + + it('뒤로 가기 버튼을 연결한다', async () => { + const wrapper = mountView(); + + await wrapper.get('.mypage-header__back').trigger('click'); + + expect(mocks.back).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/tests/report/AnalysisView.test.js b/frontend/src/views/report/AnalysisView.test.js similarity index 64% rename from frontend/src/tests/report/AnalysisView.test.js rename to frontend/src/views/report/AnalysisView.test.js index edae587..cb3fa20 100644 --- a/frontend/src/tests/report/AnalysisView.test.js +++ b/frontend/src/views/report/AnalysisView.test.js @@ -70,6 +70,7 @@ const report = { detailAddress: '101호', deposit: 100_000_000, result: 'SAFE', + ratio: 0.5556, favorite: false, checkResults: [], fraudTypes: [], @@ -78,6 +79,9 @@ const report = { describe('분석 결과 화면', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.authStore.isAuthenticated = false; + mocks.authStore.myPage = null; + mocks.authStore.fetchMyPage.mockResolvedValue({ characterType: 'CAT' }); mocks.getReport.mockResolvedValue(report); mocks.getChecklists.mockResolvedValue([]); mocks.shareReport.mockResolvedValue({ shareToken: 'share-token' }); @@ -102,9 +106,56 @@ describe('분석 결과 화면', () => { expect(mocks.getReport).toHaveBeenCalledWith('12'); expect(wrapper.text()).toContain('서울시 마포구 101호'); expect(wrapper.text()).toContain('1억 0만원'); + expect(wrapper.get('.ratio-field strong').text()).toBe('55.56%'); expect(wrapper.get('.address-icon').attributes('src')).toBe(ReportIcon); }); + test('위험도 요약은 세부 항목 집계가 아닌 상세 응답의 result를 표시한다', async () => { + mocks.getReport.mockResolvedValue({ + ...report, + result: 'DANGER', + checkResults: [], + fraudTypes: [], + }); + + const wrapper = mount(AnalysisView); + await flushPromises(); + + expect(wrapper.get('.risk-summary').classes()).toContain( + 'risk-summary--danger', + ); + }); + + test('로그인 프로필 조회 실패를 기록하고 리포트는 계속 표시한다', async () => { + const error = new Error('profile failed'); + mocks.authStore.isAuthenticated = true; + mocks.authStore.fetchMyPage.mockRejectedValue(error); + + const wrapper = mount(AnalysisView); + await flushPromises(); + + expect(logger.error).toHaveBeenCalledWith('analysis.fetch-user', error); + expect(wrapper.text()).toContain('서울시 마포구 101호'); + }); + + test('미리보기 라우트는 API 요청 없이 시나리오 데이터를 표시한다', async () => { + mocks.route.meta = { analysisPreview: true }; + mocks.route.params = { + analysisReportId: undefined, + scenario: 'b', + shareToken: undefined, + }; + + const wrapper = mount(AnalysisView); + await flushPromises(); + + expect(mocks.getReport).not.toHaveBeenCalled(); + expect(wrapper.get('.risk-summary').classes()).toContain( + 'risk-summary--danger', + ); + expect(wrapper.get('.ratio-field strong').text()).toBe('85%'); + }); + test('상세 응답의 즐겨찾기 상태를 표시하고 해제 API를 호출한다', async () => { mocks.getReport.mockResolvedValue({ ...report, favorite: true }); const wrapper = mount(AnalysisView); @@ -156,6 +207,74 @@ describe('분석 결과 화면', () => { expect(wrapper.text()).toContain('링크를 복사했습니다.'); }); + test('클립보드 API 실패 시 textarea 방식으로 링크를 복사한다', async () => { + mocks.clipboardWrite.mockRejectedValue(new Error('denied')); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn(() => true), + }); + const wrapper = mount(AnalysisView); + await flushPromises(); + + await wrapper.get('.share-button').trigger('click'); + await flushPromises(); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('textarea')).toBeNull(); + }); + + test('복사 알림은 지정 시간이 지나면 자동으로 숨긴다', async () => { + const wrapper = mount(AnalysisView); + await flushPromises(); + const timeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation((callback) => { + callback(); + return 1; + }); + + await wrapper.get('.share-button').trigger('click'); + await flushPromises(); + + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + expect(wrapper.text()).not.toContain('링크를 복사했습니다.'); + timeoutSpy.mockRestore(); + }); + + test('즐겨찾기 실패를 화면에 표시한다', async () => { + mocks.addReportFavorite.mockRejectedValue({ + response: { data: { message: '즐겨찾기 실패' } }, + }); + const wrapper = mount(AnalysisView); + await flushPromises(); + + await wrapper.get('.favorite-button').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.result-actions__error').text()).toBe( + '즐겨찾기 실패', + ); + expect(logger.error).toHaveBeenCalledWith( + 'analysis.toggle-favorite', + expect.anything(), + { analysisReportId: 12 }, + ); + }); + + test('공유 토큰이 없으면 오류를 표시한다', async () => { + mocks.shareReport.mockResolvedValue({}); + const wrapper = mount(AnalysisView); + await flushPromises(); + + await wrapper.get('.share-button').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.result-actions__error').text()).not.toBe(''); + expect(logger.error).toHaveBeenCalledWith( + 'analysis.share-report', + expect.any(Error), + { analysisReportId: 12 }, + ); + }); + test('목록 보기는 리포트 목록으로 이동한다', async () => { const wrapper = mount(AnalysisView); await flushPromises(); @@ -211,6 +330,26 @@ describe('분석 결과 화면', () => { }); }); + test('체크리스트 확인 실패를 화면에 표시한다', async () => { + mocks.getChecklists.mockRejectedValue({ + response: { data: { message: '체크리스트 실패' } }, + }); + const wrapper = mount(AnalysisView); + await flushPromises(); + + await wrapper.get('.result-actions__button--primary').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.result-actions__error').text()).toBe( + '체크리스트 실패', + ); + expect(logger.error).toHaveBeenCalledWith( + 'analysis.open-checklist', + expect.anything(), + { analysisReportId: 12 }, + ); + }); + test('공유 라우트는 토큰으로 리포트를 조회하고 소유자 동작을 숨긴다', async () => { mocks.route.meta = { analysisShared: true }; mocks.route.params = { diff --git a/frontend/src/views/report/AnalysisView.vue b/frontend/src/views/report/AnalysisView.vue index a501782..95be629 100644 --- a/frontend/src/views/report/AnalysisView.vue +++ b/frontend/src/views/report/AnalysisView.vue @@ -22,7 +22,6 @@ import { DEFAULT_FRAUD_TYPES, DEFAULT_SECRETARY_IMAGES, DEFAULT_SPECIAL_TERMS, - MARKET_PRICE, RISK_ICONS, RISK_LABELS, SECRETARY_IMAGES, @@ -36,7 +35,6 @@ import { import { aggregateRiskStatuses, formatKoreanDeposit, - getRentRatio, selectSecretaryValue, toNumericAmount, } from '@/utils/report/analysis'; @@ -73,6 +71,8 @@ const reportActionError = ref(''); const checks = ref(DEFAULT_CHECKS); const fraudTypes = ref(DEFAULT_FRAUD_TYPES); const specialTerms = ref(DEFAULT_SPECIAL_TERMS); +const overallRisk = ref('safe'); +const rentRatioDisplay = ref('-'); let toastTimer; onMounted(async () => { @@ -93,12 +93,6 @@ const fraudStatuses = computed(() => fraudTypes.value.flatMap(({ items }) => items.map(({ status }) => status)), ); const fraudRisk = computed(() => aggregateRiskStatuses(fraudStatuses.value)); -const overallRisk = computed(() => - aggregateRiskStatuses([ - ...checks.value.map(({ status }) => status), - ...fraudStatuses.value, - ]), -); const secretaryImage = computed(() => { const images = selectSecretaryValue(SECRETARY_IMAGES, activeSecretary.value); @@ -122,11 +116,6 @@ const numericDeposit = computed(() => toNumericAmount(deposit.value)); const formattedDeposit = computed(() => deposit.value === '-' ? '-' : formatKoreanDeposit(numericDeposit.value), ); -const rentRatioDisplay = computed(() => - deposit.value === '-' - ? '-' - : `${getRentRatio(numericDeposit.value, MARKET_PRICE)}%`, -); const applyReport = (report) => { const mapped = mapReportDetail(report); @@ -134,6 +123,8 @@ const applyReport = (report) => { currentReportId.value = mapped.analysisReportId ?? null; address.value = mapped.address; deposit.value = mapped.deposit; + overallRisk.value = mapped.risk; + rentRatioDisplay.value = mapped.ratio; isFavorite.value = mapped.favorite; secretary.value = mapped.secretary ?? secretary.value; checks.value = mapped.checks; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e3298d4..8e951e5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -15,8 +15,9 @@ export default defineConfig({ test: { environment: 'jsdom', coverage: { + all: true, provider: 'v8', - reporter: ['text', 'html', 'lcov'], + reporter: ['text', 'html', 'lcov', 'json-summary'], reportsDirectory: './coverage', thresholds: { statements: 90, @@ -24,49 +25,10 @@ export default defineConfig({ functions: 90, lines: 90, }, - include: [ - 'src/api/auth.js', - 'src/api/token.js', - 'src/stores/auth.js', - 'src/utils/authValidator.js', - 'src/composables/useSignupForm.js', - 'src/composables/useDictionaryCharacter.js', - 'src/utils/logger.js', - 'src/components/auth/AuthInputBox.vue', - 'src/views/auth/LoginView.vue', - 'src/views/auth/SignupView.vue', - 'src/api/map.js', - 'src/api/report.js', - 'src/api/address.js', - 'src/api/analysisReport.js', - 'src/api/utils/error.js', - 'src/constants/map/regionMap.js', - 'src/constants/report/list.js', - 'src/utils/map/**/*.js', - 'src/utils/report/date.js', - 'src/utils/report/list.js', - 'src/utils/report/analysisAuth.js', - 'src/utils/report/analysisFlow.js', - 'src/utils/report/mapper.js', - 'src/composables/map/**/*.js', - 'src/composables/report/useReportList.js', - 'src/composables/report/useAnalysisFlow.js', - 'src/services/report/analysisAuth.js', - 'src/services/report/analysisFlow.js', - 'src/components/main/MainDataTabs.vue', - 'src/components/main/RiskMapCard.vue', - 'src/components/main/map/KoreaRegionMap.vue', - 'src/components/common/secretary/SecretaryCharacter.vue', - 'src/components/common/secretary/SecretaryGuide.vue', - 'src/components/report/list/*.vue', - 'src/components/report/create/*.vue', - 'src/components/report/analysis/AnalysisProgress.vue', - 'src/components/report/analysis/E_analysis.vue', - 'src/views/MainPageView.vue', - 'src/views/report/ReportListView.vue', - 'src/views/report/ReportCreateView.vue', - 'src/views/report/AnalysisProgressView.vue', - 'src/views/report/AnalysisView.vue', + include: ['src/**/*.{js,vue}'], + exclude: [ + 'src/**/*.{test,spec}.js', + 'src/tests/**', ], }, },