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
11 changes: 8 additions & 3 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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:
Expand All @@ -37,4 +42,4 @@ jobs:
run: chmod +x backend/gradlew

- name: Run tests
run: ./backend/gradlew -p backend clean test --no-daemon
run: ./backend/gradlew -p backend clean test --no-daemon
45 changes: 45 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -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

46 changes: 46 additions & 0 deletions .github/workflows/frontend-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ on:
permissions:
contents: read
packages: write
id-token: write

concurrency:
group: secondzip-frontend-production
Expand All @@ -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

Expand All @@ -42,6 +58,7 @@ jobs:
context: ./frontend
file: ./frontend/Dockerfile
push: true
platforms: linux/amd64

tags: |
ghcr.io/second-zip/second-zip-frontend:latest
Expand All @@ -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}'
16 changes: 16 additions & 0 deletions frontend/src/App.test.js
Original file line number Diff line number Diff line change
@@ -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: '<main data-test="route-view" />' } },
},
});

expect(wrapper.get('[data-test="route-view"]').exists()).toBe(true);
});
});
4 changes: 2 additions & 2 deletions frontend/src/api/instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/api/instance.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 28 additions & 0 deletions frontend/src/api/terms.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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('현재 도감');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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: {
Expand Down
Loading