Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0faead4
[myPage] fix : chore(api): v6 마이페이지 전용 axiosInstanceV6 추가
HA-SEUNG-JEONG May 23, 2026
1dab1a1
[myPage] fix : feat(my-inquiry): 1:1 문의 API 훅 및 Zod 스키마 추가
HA-SEUNG-JEONG May 23, 2026
bc19681
[myPage] fix : feat(my-page): 마이페이지 내가 작성한 글 / 1:1 문의 페이지 구현
HA-SEUNG-JEONG May 23, 2026
de057dd
[myPage] fix : 마이 클래스 토글 비활성화 기능 추가 — localStorage 기반 클라이언트 상태 관리
HA-SEUNG-JEONG May 24, 2026
e756be3
[myPage] chore : NotificationSettingResponse dead field 제거
HA-SEUNG-JEONG May 24, 2026
c10631f
[myPage] fix : 마이클래스 버튼 토큰 수정 및 코드 정리
HA-SEUNG-JEONG May 24, 2026
d4e14de
[myPage] feat : 마이페이지 배너 UI 업데이트 및 토큰 수정
HA-SEUNG-JEONG May 24, 2026
d88f208
[myPage] fix : 마이페이지 전역 primary-500 미존재 토큰 교체
HA-SEUNG-JEONG May 24, 2026
8aa2077
[myPage] chore : 마이페이지 에셋 추가
HA-SEUNG-JEONG May 24, 2026
b0b76f5
[myPage] fix : 탈퇴 확인 모달 Figma 디자인 반영
HA-SEUNG-JEONG May 24, 2026
546c3f0
[myPage] fix : chore(types): 클래스 결제 환불/취소 관련 타입 추가
HA-SEUNG-JEONG May 24, 2026
c558d1d
[myPage] fix : chore(api): 클래스 결제 상세 조회 및 환불 요청 훅 추가
HA-SEUNG-JEONG May 24, 2026
f69a9ab
[myPage] fix : feat(결제관리): 클래스 결제 취소·환불 플로우 분리 및 가상계좌 상세 조회
HA-SEUNG-JEONG May 24, 2026
3f52f32
[myPage] fix : feat(마이피드): 임시 저장 피드 목록 UI 추가
HA-SEUNG-JEONG May 24, 2026
e8031be
[myPage] fix : chore(types): 빌더 피드 관리 응답 타입 추가
HA-SEUNG-JEONG May 24, 2026
29cafd3
[myPage] fix : 문의 첨부 미연동 숨김, 알림 토글 hydration 및 에러 처리 개선
HA-SEUNG-JEONG May 24, 2026
33af944
[myPage] fix : chore(types): MyDraftBuilderFeedItemResponse 미사용 타입 제거
HA-SEUNG-JEONG May 24, 2026
8d7bc20
[myPage] fix : refactor(api): useGetMyDraftBuilderFeeds 중복 훅 제거
HA-SEUNG-JEONG May 24, 2026
0db37fc
[myPage] fix : fix(임시저장): draft 중복 생성 방지 및 DRAFT→PUBLISHED 전환 처리
HA-SEUNG-JEONG May 24, 2026
cca0ecf
[myPage] fix : fix(피드 등록): 임시저장 발행 후 피드 상세 페이지로 이동
HA-SEUNG-JEONG May 24, 2026
f948eed
[myPage] fix : 테스트 코드 수정
HA-SEUNG-JEONG May 24, 2026
3b83647
[myPage] fix : fix(ci): 스테이징 서버 다운 시 E2E 스킵으로 CI 보호
HA-SEUNG-JEONG May 25, 2026
06afccf
[myPage] fix : fix(ci): non-@auth E2E를 로컬 서버 대상으로 실행해 스테이징 의존성 제거
HA-SEUNG-JEONG May 25, 2026
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
44 changes: 35 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ jobs:
- name: Build Storybook
run: yarn build-storybook

# Playwright E2E: 스테이징 환경 대상 실행
# E2E_AUTH_JSON secret 설정 시 @auth 태그 포함 전체 스위트 실행
# secret 누락/만료 시 비인증 테스트만 실행 (fallback)
# Playwright E2E:
# - non-@auth 테스트: CI 내 로컬 서버(localhost:3000) 대상 항상 실행 (스테이징 의존 없음)
# - @auth 테스트: 스테이징 접속 가능 시에만 실행, 불가 시 경고 후 스킵
# 갱신 절차: yarn e2e:save-auth → GitHub Secret E2E_AUTH_JSON 업데이트
e2e:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -170,15 +170,41 @@ jobs:
if: env.E2E_AUTH_JSON == ''
run: echo "::warning::E2E_AUTH_JSON not set — skipping @auth tests"

- name: Run E2E tests (full suite, with auth)
if: env.E2E_AUTH_JSON != '' && env.AUTH_EXPIRED != 'true'
run: yarn e2e
- name: Build Next.js app
run: yarn build
env:
E2E_BASE_URL: https://test.zeroone.it.kr
NEXT_PUBLIC_API_BASE_URL: https://test-api.zeroone.it.kr

- name: Run E2E tests (non-auth only)
if: env.E2E_AUTH_JSON == '' || env.AUTH_EXPIRED == 'true'
- name: Start local server
run: |
NEXT_PUBLIC_API_BASE_URL=https://test-api.zeroone.it.kr yarn start &
for i in $(seq 1 30); do
if curl -sf --max-time 5 http://localhost:3000/ > /dev/null 2>&1; then
echo "Local server ready"
break
fi
echo "Waiting for server... ($i/30)"
sleep 2
done

- name: Run E2E tests (non-auth, local server)
run: yarn e2e --grep-invert @auth
env:
E2E_BASE_URL: http://localhost:3000

- name: Check staging connectivity
run: |
STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" https://test.zeroone.it.kr/ 2>/dev/null || true)
if [[ "$STATUS" =~ ^[2-4][0-9][0-9]$ ]]; then
echo "Staging reachable (HTTP $STATUS)"
else
echo "::warning::Staging https://test.zeroone.it.kr unreachable — skipping @auth tests"
echo "STAGING_DOWN=true" >> "$GITHUB_ENV"
fi

- name: Run E2E tests (auth suite, staging)
if: env.E2E_AUTH_JSON != '' && env.AUTH_EXPIRED != 'true' && env.STAGING_DOWN != 'true'
run: yarn e2e --grep @auth
env:
E2E_BASE_URL: https://test.zeroone.it.kr

Expand Down
153 changes: 106 additions & 47 deletions e2e/class/builder-feed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ function makeComments(): { content: BuilderFeedCommentsResponse } {
{
commentId: 1,
content: '멋진 피드네요!',
author: { memberId: 3, nickname: '댓글러', role: 'STUDENT' },
author: {
memberId: 3,
nickname: '댓글러',
role: 'STUDENT',
},
createdAt: '2025-05-01T13:00:00.000Z',
replies: [],
},
Expand All @@ -133,12 +137,27 @@ function makeComments(): { content: BuilderFeedCommentsResponse } {
}

// ─── Route Mock Helpers ───────────────────────────────────────────────────────
//
// Use URL-object function predicates (url.pathname) rather than regex strings
// so cross-origin requests to test-api.zeroone.it.kr are matched precisely.
//
// Registration order follows LIFO (last registered = first evaluated), so
// more-specific handlers are registered last.

async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) {
await page.route(/\/courses\//, async (route) => {
const url = route.request().url();
if (url.includes('/courses/vibe-intro/curriculum')) {
await route.fulfill({
// (1) course detail — single path segment after /courses/
await page.route(
(url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname),
async (route) => route.fulfill({ json: makeCourseDetail() }),
);

// (2) curriculum — /courses/{slug}/curriculum
await page.route(
(url) =>
url.pathname.startsWith('/api/v5/courses/') &&
url.pathname.endsWith('/curriculum'),
async (route) =>
route.fulfill({
json: {
content: {
courseId: COURSE_ID,
Expand All @@ -148,42 +167,64 @@ async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) {
chapters: [],
},
},
});
} else if (url.includes('/builder-feeds')) {
await route.fulfill({ json: makeFeedList(feeds) });
} else if (url.includes('/courses/vibe-intro')) {
await route.fulfill({ json: makeCourseDetail() });
} else {
await route.continue();
}
});
}),
);

// (3) builder-feeds list — /courses/{courseId}/builder-feeds (checked first via LIFO)
await page.route(
(url) =>
url.pathname.startsWith('/api/v5/courses/') &&
url.pathname.includes('/builder-feeds'),
async (route) => route.fulfill({ json: makeFeedList(feeds) }),
);
}

async function mockFeedDetailApis(page: Page) {
// Intercept builder-feeds/* routes first (more specific)
await page.route(/\/builder-feeds\//, async (route) => {
const url = route.request().url();
if (url.includes('/comments')) {
await route.fulfill({ json: makeComments() });
} else if (url.includes('/like')) {
// (1) course detail
await page.route(
(url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname),
async (route) => route.fulfill({ json: makeCourseDetail() }),
);

// (2) builder-feeds list on course (for "더 많은 피드" section)
await page.route(
(url) =>
url.pathname.startsWith('/api/v5/courses/') &&
url.pathname.includes('/builder-feeds'),
async (route) => route.fulfill({ json: makeFeedList([]) }),
);

// (3) feed detail — /builder-feeds/{id} (exact numeric id, no sub-path)
await page.route(
(url) => /\/api\/v5\/builder-feeds\/\d+$/.test(url.pathname),
async (route) => route.fulfill({ json: makeFeedDetail() }),
);

// (4) comments — /builder-feeds/{id}/comments
await page.route(
(url) =>
url.pathname.startsWith('/api/v5/builder-feeds/') &&
url.pathname.endsWith('/comments'),
async (route) => route.fulfill({ json: makeComments() }),
);

// (5) like — POST /builder-feeds/{id}/like (checked first via LIFO)
await page.route(
(url) =>
url.pathname.startsWith('/api/v5/builder-feeds/') &&
url.pathname.endsWith('/like'),
async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
await route.fulfill({
json: { content: { feedId: FEED_ID, isLiked: true, likeCount: 6 } },
json: {
content: { feedId: FEED_ID, isLiked: true, likeCount: 6 },
},
});
} else {
await route.fulfill({ json: makeFeedDetail() });
}
});
// Intercept /courses/* for "더 많은 피드" and course detail
await page.route(/\/courses\//, async (route) => {
const url = route.request().url();
if (url.includes('/builder-feeds')) {
await route.fulfill({ json: makeFeedList([]) });
} else if (url.includes('/courses/vibe-intro')) {
await route.fulfill({ json: makeCourseDetail() });
} else {
await route.continue();
}
});
},
);
}

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand All @@ -193,10 +234,13 @@ test.describe('빌더 피드 목록 @auth', () => {
page,
}) => {
await mockFeedListApis(page, [makeFeedItem(FEED_ID)]);
await page.goto(FEED_LIST_PATH, { waitUntil: 'load' });
await Promise.all([
page.waitForResponse((r) => /\/courses\/vibe-intro$/.test(r.url())),
page.goto(FEED_LIST_PATH, { waitUntil: 'load' }),
]);

await expect(page.getByText(/테스트 피드 내용/)).toBeVisible({
timeout: 10000,
timeout: 5000,
});
await expect(page.getByText('테스터').first()).toBeVisible();
});
Expand All @@ -205,10 +249,13 @@ test.describe('빌더 피드 목록 @auth', () => {
page,
}) => {
await mockFeedListApis(page, []);
await page.goto(FEED_LIST_PATH, { waitUntil: 'load' });
await Promise.all([
page.waitForResponse((r) => /\/courses\/vibe-intro$/.test(r.url())),
page.goto(FEED_LIST_PATH, { waitUntil: 'load' }),
]);

await expect(page.getByText('아직 등록된 피드가 없어요.')).toBeVisible({
timeout: 10000,
timeout: 5000,
});
});
});
Expand All @@ -219,30 +266,42 @@ test.describe('빌더 피드 상세 @auth', () => {
});

test('피드 상세 렌더링 — 내용·댓글 표시', async ({ page }) => {
await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' });
await Promise.all([
page.waitForResponse(
(r) =>
/\/builder-feeds\/\d+$/.test(r.url()) &&
r.request().method() === 'GET',
),
page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }),
]);

await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({
timeout: 10000,
timeout: 5000,
});
await expect(page.getByText('멋진 피드네요!')).toBeVisible({
timeout: 5000,
timeout: 10000,
});
});

test('좋아요 버튼 클릭 → POST /builder-feeds/{id}/like 호출 확인', async ({
page,
}) => {
await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' });
await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({
timeout: 10000,
});
await Promise.all([
page.waitForResponse(
(r) =>
/\/builder-feeds\/\d+$/.test(r.url()) &&
r.request().method() === 'GET',
),
page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }),
]);

// Like button is the first action button (Heart icon + likeCount)
const [likeResponse] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes(`/builder-feeds/${FEED_ID}/like`) &&
r.request().method() === 'POST',
{ timeout: 15000 },
),
page.locator('button').filter({ hasText: '5' }).first().click(),
]);
Expand Down
Binary file added public/my-page/discord-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/my-page/feed-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions src/api/client/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,22 @@ axiosInstanceForMultipartV5.interceptors.response.use(
axiosInstanceForMultipartV5(requestConfig),
),
);

// v6 MyPage API 전용 인스턴스
export const axiosInstanceV6 = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v6/`,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});

attachApiLogger(axiosInstanceV6, 'client-v6-json');
axiosInstanceV6.interceptors.request.use(attachAccessTokenToRequest);
axiosInstanceV6.interceptors.response.use(
(config) => config,
createClientAuthResponseRejectedHandler((requestConfig) =>
axiosInstanceV6(requestConfig),
),
);
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,11 @@ export function LessonQnaSubmissionModal({
</div>

{/* Footer */}
<div className="flex shrink-0 items-center justify-center gap-200 px-750 py-300">
<div className="flex shrink-0 items-center gap-200 px-750 py-300">
<button
type="button"
onClick={handleDraftSave}
className="w-full rounded-100 border border-rose-400 px-400 py-200 font-designer-18b text-rose-500 hover:opacity-80"
className="flex h-700 flex-1 items-center justify-center rounded-100 border border-rose-400 font-designer-18b text-rose-500 hover:opacity-80"
>
임시저장
</button>
Expand All @@ -299,7 +299,7 @@ export function LessonQnaSubmissionModal({
onClick={handleSubmit}
disabled={createQna.isPending}
className={cn(
'w-full rounded-100 px-400 py-200 font-designer-18b text-text-inverse transition-opacity',
'flex h-700 flex-1 items-center justify-center rounded-100 font-designer-18b text-text-inverse transition-opacity',
createQna.isPending
? 'cursor-not-allowed bg-gray-300'
: 'bg-rose-500 hover:opacity-90',
Expand Down
Loading
Loading