Skip to content

Commit 991eaa5

Browse files
authored
chore: 테스트 인프라·테스트 케이스 보강 및 API 확장 (#58)
- API: Testcontainers(Redis) 적용, RegistrationConcurrencyTest 활성화 - Schedule/User API 확장, .cursorrules 테크 스택·필수 테스트 범위 갱신 - Web: SignInForm/SignUpForm, useRegistrationStore, parseVtt 등 단위·통합 테스트 추가 - test-utils.tsx, jest.setup 보완
1 parent 110f8ae commit 991eaa5

27 files changed

Lines changed: 1377 additions & 75 deletions

.cursorrules

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,29 @@
1212
# Tech Stack & Versions
1313
## Backend (Java/Spring Boot)
1414
- **Framework**: Spring Boot 3.3.3
15-
- **Language**: Java 17 (Use Records for DTOs if applicable, strictly typed)
15+
- **Language**: Java 21 (Use Records for DTOs if applicable, strictly typed)
1616
- **Build Tool**: Gradle (Kotlin DSL)
1717
- **Database**: PostgreSQL (Production), H2 (Test)
1818
- **ORM**: JPA / Hibernate
1919
- **API Docs**: SpringDoc OpenAPI (Swagger)
2020
- **Utils**: Lombok, Commons Lang3
21+
- **Config**: spring-dotenv (.env 로드)
22+
- **Concurrency**: Redisson (Redis 분산 락)
23+
- **SQL Logging**: p6spy
24+
- **AI/STT**: Google Cloud Speech, Google GenAI (Gemini)
2125

2226
## Frontend (Next.js)
2327
- **Framework**: Next.js 15.5.6 (App Router)
2428
- **Library**: React 19.1.0
2529
- **Language**: TypeScript
2630
- **Styling**: Tailwind CSS v4 (No config file, strictly utility classes)
2731
- **State Management**: Zustand (Client), TanStack Query v5 (Server)
28-
- **Forms**: React Hook Form + Zod
32+
- **Forms**: React Hook Form + Zod + @hookform/resolvers
33+
- **UI**: Radix UI (shadcn/ui 기반), lucide-react (아이콘)
2934
- **Auth**: Better-Auth (Do NOT suggest NextAuth)
3035
- **HTTP Client**: Axios
36+
- **Toast**: Sonner
37+
- **Date**: date-fns
3138
- **Testing**: Jest, React Testing Library (RTL)
3239

3340
# Coding Guidelines
@@ -67,7 +74,8 @@
6774
- **Type Safety**: Share types with Backend or define strictly in `types/`. Use Zod for validation.
6875
- **Testing**:
6976
- Use **Jest** and **React Testing Library** for Unit/Integration tests.
70-
- Test complex hooks and critical form flows (Login, Registration).
77+
- **필수**: Login(SignInForm), Registration(SignUpForm), 수강 신청 플로우(CourseRegistrationSchema, useRegistrationStore).
78+
- **권장**: TanStack Query 훅(useCourseList, useFindCandidates, useRegisterCourse), 관리자 폼(UserCreateForm), 유틸(parseVtt, parseFeedbackContent), 훅(useCountdown).
7179
- **Auth**: Implement authentication flows using `better-auth`.
7280

7381
## 4. General Workflow

api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ dependencies {
3434
annotationProcessor("org.projectlombok:lombok")
3535

3636
testImplementation("org.springframework.boot:spring-boot-starter-test")
37+
testImplementation("org.testcontainers:testcontainers:1.20.4")
38+
testImplementation("org.testcontainers:junit-jupiter:1.20.4")
3739
testRuntimeOnly("com.h2database:h2")
3840
testCompileOnly("org.projectlombok:lombok")
3941
testAnnotationProcessor("org.projectlombok:lombok")

api/src/main/java/org/junotb/api/schedule/ScheduleController.java

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.junotb.api.schedule;
22

3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
35
import jakarta.validation.Valid;
46
import lombok.RequiredArgsConstructor;
57
import org.junotb.api.common.web.PageResponse;
@@ -16,13 +18,24 @@
1618
import java.util.Map;
1719
import java.util.Objects;
1820

21+
/**
22+
* 수업 스케줄 관리 API. 강사는 본인 스케줄 생성·수정·삭제, Meet 링크 등록.
23+
*/
24+
@Tag(name = "Schedule", description = "수업 스케줄 관리 API")
1925
@RestController
2026
@RequestMapping("/api/v1/schedule")
2127
@RequiredArgsConstructor
2228
public class ScheduleController {
2329
private final ScheduleService scheduleService;
2430

25-
// 스케줄 목록 조회
31+
/**
32+
* 스케줄 목록을 페이징하여 조회. 강사·코스·상태로 필터링 가능.
33+
*
34+
* @param request 필터 조건 (userId, courseId, status)
35+
* @param pageable 페이징
36+
* @return 페이징된 스케줄 목록
37+
*/
38+
@Operation(summary = "스케줄 목록 조회", description = "강사·코스·상태로 필터링하여 페이징 조회합니다.")
2639
@GetMapping("")
2740
public PageResponse<ScheduleResponse> list(@ModelAttribute ScheduleListRequest request, Pageable pageable) {
2841
ScheduleListRequest safeRequest = Objects.requireNonNullElse(request, ScheduleListRequest.empty());
@@ -32,13 +45,27 @@ public PageResponse<ScheduleResponse> list(@ModelAttribute ScheduleListRequest r
3245
);
3346
}
3447

35-
// 스케줄 조회
48+
/**
49+
* ID로 스케줄 조회.
50+
*
51+
* @param id 스케줄 ID
52+
* @return 스케줄 정보 (없으면 404)
53+
*/
54+
@Operation(summary = "스케줄 조회", description = "ID로 스케줄을 조회합니다.")
3655
@GetMapping("/{id}")
3756
public ResponseEntity<ScheduleResponse> get(@PathVariable Long id) {
3857
return ResponseEntity.of(scheduleService.findById(id).map(ScheduleResponse::from));
3958
}
4059

41-
// 스케줄 생성
60+
/**
61+
* 새 수업 스케줄 생성. 인증된 사용자(강사)가 자신의 스케줄로 등록.
62+
*
63+
* @param userId 인증된 강사 ID
64+
* @param request 생성 요청 (courseId, startsAt, endsAt, status)
65+
* @return 생성된 스케줄
66+
* @throws ResourceNotFoundException 강사 또는 코스 미존재 시
67+
*/
68+
@Operation(summary = "스케줄 생성", description = "강사가 수업 일정을 등록합니다.")
4269
@PostMapping
4370
public ResponseEntity<ScheduleResponse> create(
4471
@AuthenticationPrincipal String userId,
@@ -48,7 +75,15 @@ public ResponseEntity<ScheduleResponse> create(
4875
return ResponseEntity.ok(ScheduleResponse.from(schedule));
4976
}
5077

51-
// 스케줄 수정
78+
/**
79+
* 스케줄 수정. null이 아닌 필드만 업데이트.
80+
*
81+
* @param id 스케줄 ID
82+
* @param request 수정 요청 (startsAt, endsAt, status)
83+
* @return 수정된 스케줄
84+
* @throws ResourceNotFoundException 스케줄 미존재 시
85+
*/
86+
@Operation(summary = "스케줄 수정", description = "null이 아닌 필드만 부분 수정합니다.")
5287
@PatchMapping("/{id}")
5388
public ResponseEntity<ScheduleResponse> update(
5489
@PathVariable Long id,
@@ -59,8 +94,16 @@ public ResponseEntity<ScheduleResponse> update(
5994
}
6095

6196
/**
62-
* 강사 전용. Meet 링크 등록/수정.
97+
* 강사 전용. 해당 스케줄의 Google Meet 링크 등록/수정.
98+
*
99+
* @param id 스케줄 ID
100+
* @param userId 인증된 강사 ID (본인 확인용)
101+
* @param request meet 링크 요청
102+
* @return 수정된 스케줄
103+
* @throws ResourceNotFoundException 스케줄 미존재 시
104+
* @throws IllegalStateException 요청자가 해당 스케줄의 강사가 아님
63105
*/
106+
@Operation(summary = "Meet 링크 등록/수정", description = "강사 전용. 수업의 Meet 링크를 등록하거나 수정합니다.")
64107
@PatchMapping("/{id}/meet-link")
65108
public ResponseEntity<ScheduleResponse> updateMeetLink(
66109
@PathVariable Long id,
@@ -71,14 +114,27 @@ public ResponseEntity<ScheduleResponse> updateMeetLink(
71114
return ResponseEntity.ok(ScheduleResponse.from(schedule));
72115
}
73116

74-
// 스케줄 삭제
117+
/**
118+
* 스케줄 삭제(취소). 논리 삭제로 상태를 CANCELED로 변경.
119+
*
120+
* @param id 스케줄 ID
121+
* @return 204 No Content
122+
* @throws ResourceNotFoundException 스케줄 미존재 시
123+
*/
124+
@Operation(summary = "스케줄 삭제", description = "논리 삭제로 취소 처리합니다.")
75125
@DeleteMapping("/{id}")
76126
public ResponseEntity<Void> delete(@PathVariable Long id) {
77127
scheduleService.delete(id);
78128
return ResponseEntity.noContent().build();
79129
}
80130

81-
// 스케줄 상태별 통계 조회
131+
/**
132+
* 강사별 스케줄 상태 통계. 대시보드용.
133+
*
134+
* @param userId 인증된 강사 ID
135+
* @return 상태(ScheduleStatus)별 건수
136+
*/
137+
@Operation(summary = "상태별 통계", description = "강사의 스케줄 상태별 건수를 반환합니다.")
82138
@GetMapping("/stats/status")
83139
public ResponseEntity<Map<ScheduleStatus, Long>> countByStatus(@AuthenticationPrincipal String userId) {
84140
Map<ScheduleStatus, Long> stats = scheduleService.countByStatus(userId);

api/src/main/java/org/junotb/api/schedule/ScheduleService.java

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.junotb.api.schedule;
22

3-
import jakarta.persistence.EntityExistsException;
43
import jakarta.persistence.criteria.Predicate;
54
import lombok.RequiredArgsConstructor;
65
import org.junotb.api.common.exception.ResourceNotFoundException;
@@ -20,6 +19,9 @@
2019

2120
import java.util.*;
2221

22+
/**
23+
* 수업 스케줄 CRUD 및 통계 서비스.
24+
*/
2325
@Service
2426
@Transactional(readOnly = true)
2527
@RequiredArgsConstructor
@@ -28,31 +30,39 @@ public class ScheduleService {
2830
private final UserRepository userRepository;
2931
private final CourseRepository courseRepository;
3032

31-
// 스케줄 조회
33+
/**
34+
* ID로 스케줄 조회.
35+
*
36+
* @param id 스케줄 ID
37+
* @return 스케줄 (없으면 Optional.empty())
38+
*/
3239
public Optional<Schedule> findById(Long id) {
3340
return scheduleRepository.findById(id);
3441
}
3542

36-
// 스케줄 목록 조회
43+
/**
44+
* 스케줄 목록 조회. Specification으로 동적 필터 적용.
45+
*
46+
* @param request 필터 (userId, courseId, status)
47+
* @param pageable 페이징
48+
* @return 페이징된 목록
49+
*/
3750
public Page<Schedule> findList(ScheduleListRequest request, Pageable pageable) {
3851
Specification<Schedule> spec = (root, query, cb) -> {
3952
List<Predicate> predicates = new ArrayList<>();
4053

41-
// 사용자 번호로 필터링
4254
if (request.userId() != null) {
4355
predicates.add(
4456
cb.equal(root.get("user").get("id"), request.userId())
4557
);
4658
}
4759

48-
// 코스 번호로 필터링
4960
if (request.courseId() != null) {
5061
predicates.add(
5162
cb.equal(root.get("course").get("id"), request.courseId())
5263
);
5364
}
5465

55-
// 스케줄 상태로 필터링
5666
if (request.status() != null) {
5767
predicates.add(
5868
cb.equal(root.get("status"), request.status())
@@ -65,15 +75,20 @@ public Page<Schedule> findList(ScheduleListRequest request, Pageable pageable) {
6575
return scheduleRepository.findAll(spec, pageable);
6676
}
6777

68-
// 스케줄 생성
78+
/**
79+
* 스케줄 생성. 강사·코스 존재 검증 후 저장.
80+
*
81+
* @param userId 강사(사용자) ID
82+
* @param request 생성 요청
83+
* @return 저장된 스케줄
84+
* @throws ResourceNotFoundException 강사 또는 코스 미존재 시
85+
*/
6986
@Transactional
7087
public Schedule create(String userId, ScheduleCreateRequest request) {
71-
// 강사(사용자) 존재 여부 확인
7288
User user = userRepository.findById(userId).orElseThrow(() ->
7389
new ResourceNotFoundException("User", userId)
7490
);
7591

76-
// 과목(코스) 존재 여부 확인
7792
Course course = courseRepository.findById(request.courseId()).orElseThrow(() ->
7893
new ResourceNotFoundException("Course", request.courseId().toString())
7994
);
@@ -89,15 +104,20 @@ public Schedule create(String userId, ScheduleCreateRequest request) {
89104
return scheduleRepository.save(schedule);
90105
}
91106

92-
// 스케줄 수정
107+
/**
108+
* 스케줄 수정. null이 아닌 필드만 업데이트.
109+
*
110+
* @param id 스케줄 ID
111+
* @param request 수정 요청
112+
* @return 수정된 스케줄
113+
* @throws ResourceNotFoundException 스케줄 미존재 시
114+
*/
93115
@Transactional
94116
public Schedule update(Long id, ScheduleUpdateRequest request) {
95-
// 스케줄 번호에 해당하는 스케줄 존재 여부 확인
96117
Schedule schedule = scheduleRepository.findById(id).orElseThrow(() ->
97-
new EntityExistsException("Schedule not found with id: " + id)
118+
new ResourceNotFoundException("Schedule", id.toString())
98119
);
99120

100-
// 수정할 필드만 업데이트
101121
if (request.startsAt() != null) schedule.setStartsAt(request.startsAt());
102122
if (request.endsAt() != null) schedule.setEndsAt(request.endsAt());
103123
if (request.status() != null) schedule.setStatus(request.status());
@@ -128,25 +148,32 @@ public Schedule updateMeetLink(Long scheduleId, String teacherId, ScheduleMeetLi
128148
return schedule;
129149
}
130150

131-
// 스케줄 삭제 (취소 처리)
151+
/**
152+
* 스케줄 삭제(취소). 이미 CANCELED면 idempotent로 조기 반환.
153+
*
154+
* @param id 스케줄 ID
155+
* @throws ResourceNotFoundException 스케줄 미존재 시
156+
*/
132157
@Transactional
133158
public void delete(Long id) {
134-
// 스케줄 번호에 해당하는 스케줄 존재 여부 확인
135159
Schedule schedule = scheduleRepository.findById(id).orElseThrow(
136-
() -> new EntityExistsException("Schedule not found with id: " + id)
160+
() -> new ResourceNotFoundException("Schedule", id.toString())
137161
);
138162

139-
// 이미 취소된 스케줄인 경우 처리하지 않음
140163
if (schedule.getStatus() == ScheduleStatus.CANCELED) return;
141164
schedule.setStatus(ScheduleStatus.CANCELED);
142165
}
143166

144-
// 상태별 스케줄 개수 조회
167+
/**
168+
* 강사별 상태별 스케줄 개수. countByStatus()가 반환하지 않는 상태는 0으로 초기화.
169+
*
170+
* @param userId 강사 ID
171+
* @return 상태별 건수
172+
*/
145173
@Transactional(readOnly = true)
146174
public Map<ScheduleStatus, Long> countByStatus(String userId) {
147175
EnumMap<ScheduleStatus, Long> result = new EnumMap<>(ScheduleStatus.class);
148176

149-
// 모든 상태를 0으로 초기화
150177
for (ScheduleStatus status : ScheduleStatus.values()) {
151178
result.put(status, 0L);
152179
}

api/src/main/java/org/junotb/api/user/TeacherAvailabilityController.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.junotb.api.user;
22

3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
35
import jakarta.validation.Valid;
46
import lombok.RequiredArgsConstructor;
57
import org.junotb.api.user.web.TeacherAvailabilityRequest;
@@ -10,19 +12,24 @@
1012

1113
import java.util.List;
1214

15+
/**
16+
* 강사 가용 시간 설정 API. Time Block 사전 INSERT 대신 조회 시점 계산에 사용.
17+
*/
18+
@Tag(name = "Teacher Availability", description = "강사 가용 시간 설정 API")
1319
@RestController
1420
@RequestMapping("/api/v1/teachers")
1521
@RequiredArgsConstructor
1622
public class TeacherAvailabilityController {
1723
private final TeacherAvailabilityService teacherAvailabilityService;
1824

1925
/**
20-
* 강사의 가용 시간 설정을 업데이트합니다.
26+
* 강사의 가용 시간 설정을 업데이트합니다. 기존 설정을 전체 교체.
2127
*
22-
* @param userId 인증된 사용자 ID (강사)
28+
* @param userId 인증된 사용자 ID (강사)
2329
* @param requests 가용 시간 설정 요청 목록
2430
* @return 업데이트된 가용 시간 설정 목록
2531
*/
32+
@Operation(summary = "가용 시간 업데이트", description = "강사의 요일별 가용 시간을 전체 교체합니다.")
2633
@PutMapping("/me/availability")
2734
public ResponseEntity<List<TeacherAvailabilityResponse>> updateAvailability(
2835
@AuthenticationPrincipal String userId,
@@ -39,6 +46,7 @@ public ResponseEntity<List<TeacherAvailabilityResponse>> updateAvailability(
3946
* @param userId 인증된 사용자 ID (강사)
4047
* @return 가용 시간 설정 목록
4148
*/
49+
@Operation(summary = "가용 시간 조회", description = "강사의 요일별 가용 시간을 조회합니다.")
4250
@GetMapping("/me/availability")
4351
public ResponseEntity<List<TeacherAvailabilityResponse>> getAvailability(
4452
@AuthenticationPrincipal String userId

0 commit comments

Comments
 (0)