diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff1cd18bd..4b6db06ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/e2e/class/builder-feed.spec.ts b/e2e/class/builder-feed.spec.ts index 4d052ad2b..bed988e99 100644 --- a/e2e/class/builder-feed.spec.ts +++ b/e2e/class/builder-feed.spec.ts @@ -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: [], }, @@ -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, @@ -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 ──────────────────────────────────────────────────────────────────── @@ -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(); }); @@ -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, }); }); }); @@ -219,23 +266,34 @@ 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([ @@ -243,6 +301,7 @@ test.describe('빌더 피드 상세 @auth', () => { (r) => r.url().includes(`/builder-feeds/${FEED_ID}/like`) && r.request().method() === 'POST', + { timeout: 15000 }, ), page.locator('button').filter({ hasText: '5' }).first().click(), ]); diff --git a/public/my-page/discord-icon.png b/public/my-page/discord-icon.png new file mode 100644 index 000000000..802a0a719 Binary files /dev/null and b/public/my-page/discord-icon.png differ diff --git a/public/my-page/feed-icon.svg b/public/my-page/feed-icon.svg new file mode 100644 index 000000000..b22fc9e26 --- /dev/null +++ b/public/my-page/feed-icon.svg @@ -0,0 +1,3 @@ + diff --git a/src/api/client/axios.ts b/src/api/client/axios.ts index 5e8f9f97d..c1c46f7fb 100644 --- a/src/api/client/axios.ts +++ b/src/api/client/axios.ts @@ -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), + ), +); diff --git a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx index 916d9d5e2..b6cfb563a 100644 --- a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx @@ -286,11 +286,11 @@ export function LessonQnaSubmissionModal({ {/* Footer */} -
- 피드 등록을 취소하시겠습니까? -
-- 작성된 내용은 저장되지 않습니다. -
-유의사항
+질문 등록을 취소하시겠습니까?
-작성된 내용은 저장되지 않습니다.
++ 어떤 레슨에 대한 질문인가요? +
+- 어떤 레슨에 대한 질문인가요? -
-- * 최대 10개의 사진을 등록할 수 있어요. -
-+ * 최대 10개의 사진을 등록할 수 있어요. +
+유의사항
+
+ 학습 알림톡을 끄면 매일 설정한 시간에 알림을 받지 못합니다.
+
+ 정말 끄시겠습니까?
+
학습이 가장 잘 챙겨지는 시간으로 알림톡을 받아보세요. @@ -191,13 +206,13 @@ function AlarmCard({
{timeText} @@ -206,11 +221,12 @@ function AlarmCard({