Skip to content

Commit 404bbaa

Browse files
Merge pull request #591 from code-zero-to-one/feat/class
질문답변, 학습 여정 맵, 빌더 피드 작업
2 parents c547e93 + c29ac4e commit 404bbaa

43 files changed

Lines changed: 4032 additions & 1319 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Live API Schema Auto-Check
2+
3+
Whenever a task touches any of the following files, **automatically** fetch and verify the live Swagger schema — no user prompt required:
4+
5+
- `src/types/api/*.types.ts`
6+
- `src/hooks/queries/**/*.ts`
7+
- `src/api/client/*.ts`
8+
9+
## When to Run
10+
11+
Trigger this check at task start (before writing code), not after:
12+
13+
- Adding or modifying a DTO interface
14+
- Adding or modifying a query/mutation hook
15+
- Reviewing a component that calls an API hook
16+
17+
## How to Fetch
18+
19+
Use `ctx_execute` (never `WebFetch` — blocked by hook):
20+
21+
```javascript
22+
const res = await fetch('https://test-api.zeroone.it.kr/v3/api-docs');
23+
const api = await res.json();
24+
const schemas = api.components?.schemas ?? {};
25+
const paths = api.paths ?? {};
26+
```
27+
28+
SpringDoc wraps all responses in `BaseResponse<T>` — the actual DTO is in `content.$ref`. Navigate through the wrapper to reach the real fields. When a schema has a circular `content.$ref` pointing to itself, drill into the path's `responses['200'].content['application/json'].examples` instead — the examples contain the actual field structure.
29+
30+
## What to Compare
31+
32+
For every DTO type being touched:
33+
34+
| Check | How |
35+
|---|---|
36+
| Field names | Compare frontend interface keys vs backend schema properties |
37+
| Field types | `string`/`number`/`boolean`/`array` alignment |
38+
| Optionality | `?` in frontend vs `required[]` array in backend schema |
39+
| New fields | Backend fields absent from frontend interface → add them |
40+
| Removed fields | Frontend fields absent from backend → flag to user |
41+
| Enum values | Frontend union type vs backend `enum` array |
42+
43+
## Reporting Format
44+
45+
```
46+
DTO 비교: CourseCurriculumLessonResponse
47+
✅ lessonId, order, title, isFree, locked, estimatedMinutes
48+
❌ 누락: viewCount (backend: integer) → 추가 필요
49+
❌ 제거됨: legacyField (frontend에만 존재)
50+
```
51+
52+
Report mismatches **before writing any code**. Do not silently skip unmatched fields.
53+
54+
## Staging URLs
55+
56+
- API docs JSON: `https://test-api.zeroone.it.kr/v3/api-docs`
57+
- Swagger UI: `https://test-api.zeroone.it.kr/swagger-ui/index.html`
58+
- API base: `https://test-api.zeroone.it.kr`

e2e/class/journey-map.spec.ts

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const COURSE_ID = 1;
1313
const LESSON_FREE_ID = 101;
1414
const LESSON_LOCKED_ID = 102;
1515
const LESSON_OPTION_ID = 103;
16+
const LESSON_UNLOCKED_ID = 104;
1617
const LESSON_FREE_TITLE = '기초 세팅';
1718
const LESSON_OPTION_TITLE = 'Option 보너스';
1819
const PAGE_PATH = '/class/vibe-intro/home';
@@ -135,11 +136,73 @@ function makeCurriculum(): { content: CourseCurriculumResponse } {
135136
};
136137
}
137138

138-
function makeJourneyMap(lessons: CourseJourneyMapLessonResponse[]): {
139+
function makeCurriculumWithBadges(): { content: CourseCurriculumResponse } {
140+
return {
141+
content: {
142+
courseId: COURSE_ID,
143+
durationDays: 30,
144+
totalChapters: 1,
145+
totalLessons: 3,
146+
chapters: [
147+
{
148+
chapterId: 10,
149+
order: 1,
150+
chapterNumber: 1,
151+
title: '시작하기',
152+
description: null,
153+
estimatedMinutes: 54,
154+
lessons: [
155+
{
156+
lessonId: LESSON_FREE_ID,
157+
order: 1,
158+
title: LESSON_FREE_TITLE,
159+
description: null,
160+
isFree: true,
161+
locked: false,
162+
estimatedMinutes: 18,
163+
},
164+
{
165+
lessonId: LESSON_LOCKED_ID,
166+
order: 2,
167+
title: '심화 레슨',
168+
description: null,
169+
isFree: false,
170+
locked: true,
171+
estimatedMinutes: 18,
172+
},
173+
{
174+
lessonId: LESSON_UNLOCKED_ID,
175+
order: 3,
176+
title: '결제 완료 레슨',
177+
description: null,
178+
isFree: false,
179+
locked: false,
180+
estimatedMinutes: 18,
181+
},
182+
],
183+
},
184+
],
185+
},
186+
};
187+
}
188+
189+
type JourneyLessonInput = Omit<
190+
CourseJourneyMapLessonResponse,
191+
'chapterId' | 'chapterNumber' | 'estimatedMinutes'
192+
> &
193+
Partial<
194+
Pick<
195+
CourseJourneyMapLessonResponse,
196+
'chapterId' | 'chapterNumber' | 'estimatedMinutes'
197+
>
198+
>;
199+
200+
function makeJourneyMap(lessons: JourneyLessonInput[]): {
139201
content: {
140202
courseId: number;
141203
courseTitle: string;
142204
viewerStatus: string;
205+
learnerCount: number;
143206
lessons: CourseJourneyMapLessonResponse[];
144207
};
145208
} {
@@ -148,7 +211,13 @@ function makeJourneyMap(lessons: CourseJourneyMapLessonResponse[]): {
148211
courseId: COURSE_ID,
149212
courseTitle: '바이브 코딩 인트로',
150213
viewerStatus: 'FREE_ENROLLED',
151-
lessons,
214+
learnerCount: 24,
215+
lessons: lessons.map((l) => ({
216+
chapterId: 1,
217+
chapterNumber: 1,
218+
estimatedMinutes: 18,
219+
...l,
220+
})),
152221
},
153222
};
154223
}
@@ -471,6 +540,77 @@ test.describe('시작하기 내비게이션 @auth', () => {
471540
});
472541
});
473542

543+
// ─── Chunk 4: 커리큘럼 레슨 카드 배지 렌더링 ──────────────────────────────────
544+
545+
test.describe('커리큘럼 레슨 카드 배지 렌더링 @auth', () => {
546+
test.beforeEach(async ({ page }) => {
547+
await page.route(/\/courses\//, async (route) => {
548+
const url = route.request().url();
549+
if (url.includes('/courses/vibe-intro/curriculum')) {
550+
await route.fulfill({ json: makeCurriculumWithBadges() });
551+
} else if (/\/courses\/\d+\/journey-map/.test(url)) {
552+
await route.fulfill({
553+
json: makeJourneyMap([
554+
{
555+
lessonId: LESSON_FREE_ID,
556+
order: 1,
557+
title: LESSON_FREE_TITLE,
558+
isFree: true,
559+
status: 'IN_PROGRESS',
560+
isAccessible: true,
561+
},
562+
{
563+
lessonId: LESSON_LOCKED_ID,
564+
order: 2,
565+
title: '심화 레슨',
566+
isFree: false,
567+
status: 'LOCKED',
568+
isAccessible: false,
569+
},
570+
{
571+
lessonId: LESSON_UNLOCKED_ID,
572+
order: 3,
573+
title: '결제 완료 레슨',
574+
isFree: false,
575+
status: 'IN_PROGRESS',
576+
isAccessible: true,
577+
},
578+
]),
579+
});
580+
} else if (/\/courses\/\d+\/progress/.test(url)) {
581+
await route.fulfill({ json: makeProgress(0) });
582+
} else if (url.includes('/courses/vibe-intro')) {
583+
await route.fulfill({ json: makeCourseDetail() });
584+
} else {
585+
await route.continue();
586+
}
587+
});
588+
await gotoAndWaitForData(page);
589+
});
590+
591+
test('isFree=true → 무료 배지 표시', async ({ page }) => {
592+
await expect(page.getByText('무료').first()).toBeVisible();
593+
});
594+
595+
test('isFree=false, isAccessible=false → 잠금 배지 표시', async ({
596+
page,
597+
}) => {
598+
await expect(page.getByRole('img', { name: '잠금' })).toBeVisible();
599+
});
600+
601+
test('isFree=false, isAccessible=true → 잠금 해제 배지 표시', async ({
602+
page,
603+
}) => {
604+
await expect(page.getByRole('img', { name: '잠금 해제' })).toBeVisible();
605+
});
606+
607+
test('세 배지 동시 렌더링 — 상호 배타적', async ({ page }) => {
608+
await expect(page.getByText('무료').first()).toBeVisible();
609+
await expect(page.getByRole('img', { name: '잠금' })).toBeVisible();
610+
await expect(page.getByRole('img', { name: '잠금 해제' })).toBeVisible();
611+
});
612+
});
613+
474614
test.describe('건너뛰기 내비게이션 @auth', () => {
475615
test('Option bonus 건너뛰기 → 다른 accessible 레슨으로 이동', async ({
476616
page,

public/class/vibe-intro/journey-1st-load.svg

Lines changed: 1 addition & 1 deletion
Loading

public/class/vibe-intro/journey-load-reverse.svg

Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 2 additions & 2 deletions
Loading

public/landing/benefits-number.svg

Lines changed: 3 additions & 0 deletions
Loading
398 KB
Loading

public/landing/check-icon.svg

Lines changed: 6 additions & 0 deletions
Loading

public/landing/discord-app.png

83.4 KB
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)