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
123 changes: 51 additions & 72 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,33 @@

## ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

๋ฌธ์„œ(PDF, PPT, DOCX)๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด Google Gemini AI๊ฐ€ ์ž๋™์œผ๋กœ ํ€ด์ฆˆ๋ฅผ ์ƒ์„ฑํ•˜๋Š” Spring Boot ๊ธฐ๋ฐ˜ ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„. SSE๋ฅผ ํ†ตํ•œ ์‹ค์‹œ๊ฐ„ ์ƒ์„ฑ ์ŠคํŠธ๋ฆฌ๋ฐ, ํ€ด์ฆˆ ์„ธํŠธ ๊ด€๋ฆฌ, ํ’€์ด ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ก์„ ์ง€์›ํ•œ๋‹ค.
๋ฌธ์„œ(PDF, PPT, DOCX)๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด Google Gemini AI๊ฐ€ ์ž๋™์œผ๋กœ ํ€ด์ฆˆ๋ฅผ ์ƒ์„ฑํ•˜๋Š” Spring Boot ๊ธฐ๋ฐ˜ ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„. SSE๋ฅผ ํ†ตํ•œ ์‹ค์‹œ๊ฐ„ ์ƒ์„ฑ
์ŠคํŠธ๋ฆฌ๋ฐ, ํ€ด์ฆˆ ์„ธํŠธ ๊ด€๋ฆฌ, ํ’€์ด ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ก์„ ์ง€์›ํ•œ๋‹ค.

## ๊ธฐ์ˆ  ์Šคํƒ

| ๋ถ„๋ฅ˜ | ๊ธฐ์ˆ  | ๋ฒ„์ „ |
|---|---|---|
| ์–ธ์–ด | Java | 21 |
| ํ”„๋ ˆ์ž„์›Œํฌ | Spring Boot | 3.5.8 |
| AI | Spring AI (Google Gemini via Vertex AI) | 1.1.2 |
| ORM | Spring Data JPA + Hibernate | (Boot BOM) |
| DB | MySQL | - |
| ์ธ์ฆ | JWT (Auth0 java-jwt 4.5.0) + OAuth2 Client | - |
| ํด๋ผ์šฐ๋“œ | OCI Java SDK (Object Storage) + Cloudflare CDN + Google Cloud Storage | 3.80.3 |
| ๋ฌธ์„œ๋ณ€ํ™˜ | JODConverter (LibreOffice) | 4.4.9 |
| PDF ์ฒ˜๋ฆฌ | Apache PDFBox | 3.0.3 |
| ๋ชจ๋‹ˆํ„ฐ๋ง | Micrometer + Prometheus + Actuator | (Boot BOM) |
| ์žฅ์• ๊ฒฉ๋ฆฌ | Resilience4j (Circuit Breaker) | 2.3.0 |
| Rate Limiting | Bucket4j + Caffeine | 8.16.1 |
| API ๋ฌธ์„œ | SpringDoc OpenAPI (Swagger UI) | 2.8.8 |
| ์•”ํ˜ธํ™” | Jasypt | 3.0.5 |
| ID ๋‚œ๋…ํ™” | Hashids | 1.0.3 |
| ๋นŒ๋“œ | Gradle (Groovy DSL) | 8.14.3 |
| ์ปจํ…Œ์ด๋„ˆ | Jib (Docker) | 3.4.0 |
| ํฌ๋งทํ„ฐ | Spotless + Google Java Format | 7.0.4 / 1.25.2 |
| DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ | Flyway | (Boot BOM) |
| ํ…Œ์ŠคํŠธ | JUnit 5 | (Boot BOM) |
| ๋ถ„๋ฅ˜ | ๊ธฐ์ˆ  | ๋ฒ„์ „ |
|---------------|-----------------------------------------------------------------------|----------------|
| ์–ธ์–ด | Java | 21 |
| ํ”„๋ ˆ์ž„์›Œํฌ | Spring Boot | 3.5.8 |
| AI | Spring AI (Google Gemini via Vertex AI) | 1.1.2 |
| ORM | Spring Data JPA + Hibernate | (Boot BOM) |
| DB | MySQL | - |
| ์ธ์ฆ | JWT (Auth0 java-jwt 4.5.0) + OAuth2 Client | - |
| ํด๋ผ์šฐ๋“œ | OCI Java SDK (Object Storage) + Cloudflare CDN + Google Cloud Storage | 3.80.3 |
| ๋ฌธ์„œ๋ณ€ํ™˜ | JODConverter (LibreOffice) | 4.4.9 |
| PDF ์ฒ˜๋ฆฌ | Apache PDFBox | 3.0.3 |
| ๋ชจ๋‹ˆํ„ฐ๋ง | Micrometer + Prometheus + Actuator | (Boot BOM) |
| ์žฅ์• ๊ฒฉ๋ฆฌ | Resilience4j (Circuit Breaker) | 2.3.0 |
| Rate Limiting | Bucket4j + Caffeine | 8.16.1 |
| API ๋ฌธ์„œ | SpringDoc OpenAPI (Swagger UI) | 2.8.8 |
| ์•”ํ˜ธํ™” | Jasypt | 3.0.5 |
| ID ๋‚œ๋…ํ™” | Hashids | 1.0.3 |
| ๋นŒ๋“œ | Gradle (Groovy DSL) | 8.14.3 |
| ์ปจํ…Œ์ด๋„ˆ | Jib (Docker) | 3.4.0 |
| ํฌ๋งทํ„ฐ | Spotless + Google Java Format | 7.0.4 / 1.25.2 |
| DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ | Flyway | (Boot BOM) |
| ํ…Œ์ŠคํŠธ | JUnit 5 | (Boot BOM) |

## ๋ช…๋ น์–ด (Scripts)

Expand Down Expand Up @@ -67,15 +68,15 @@ q-asker/api/
โ”‚ โ”œโ”€โ”€ application.yml # ์„ค์ • ์ง„์ž…์  (config/ import)
โ”‚ โ”œโ”€โ”€ application-secrets.yml # ์•”ํ˜ธํ™”๋œ ์‹œํฌ๋ฆฟ
โ”‚ โ””โ”€โ”€ config/ # ๋ถ„๋ฆฌ๋œ ์„ค์ • ํŒŒ์ผ๋“ค
โ”‚ โ”œโ”€โ”€ server.yml # ์„œ๋ฒ„, DB, JPA, ์บ์‹œ
โ”‚ โ”œโ”€โ”€ ai.yml # Google Gemini AI ์„ค์ •
โ”‚ โ”œโ”€โ”€ security.yml # JWT, OAuth2, CORS
โ”‚ โ”œโ”€โ”€ aws.yml # OCI Object Storage, CDN
โ”‚ โ”œโ”€โ”€ jodconverter.yml # LibreOffice ๋ฌธ์„œ๋ณ€ํ™˜
โ”‚ โ”œโ”€โ”€ monitoring.yml # Actuator, Prometheus
โ”‚ โ”œโ”€โ”€ q-asker.yml # ์•ฑ ์ปค์Šคํ…€ ์„ค์ •
โ”‚ โ”œโ”€โ”€ resilience4j.yml # Circuit Breaker
โ”‚ โ””โ”€โ”€ springdoc.yml # Swagger/OpenAPI
โ”‚ โ”œโ”€โ”€ database-config.yml # ์„œ๋ฒ„, DB, JPA, ์บ์‹œ
โ”‚ โ”œโ”€โ”€ ai-setting.yml # Google Gemini AI ์„ค์ •
โ”‚ โ”œโ”€โ”€ spring-security.yml # JWT, OAuth2, CORS
โ”‚ โ”œโ”€โ”€ oci-bucket-config.yml # OCI Object Storage, CDN
โ”‚ โ”œโ”€โ”€ jodconverter.yml # LibreOffice ๋ฌธ์„œ๋ณ€ํ™˜
โ”‚ โ”œโ”€โ”€ actuator.yml # Actuator, Prometheus
โ”‚ โ”œโ”€โ”€ app-common.yml # ์•ฑ ์ปค์Šคํ…€ ์„ค์ •
โ”‚ โ”œโ”€โ”€ resilience.yml # Circuit Breaker
โ”‚ โ””โ”€โ”€ spring-doc.yml # Swagger/OpenAPI
โ”œโ”€โ”€ modules/
โ”‚ โ”œโ”€โ”€ global/ # ๊ณตํ†ต (BaseEntity, ApiResponse, GlobalExceptionHandler)
โ”‚ โ”œโ”€โ”€ auth/ (api + impl) # ์ธ์ฆ (JWT, OAuth2, RateLimitFilter)
Expand Down Expand Up @@ -110,54 +111,32 @@ q-asker/api/
- ํ”„๋กœํŒŒ์ผ: `local` (๊ฐœ๋ฐœ), `prod` (์šด์˜)
- Actuator ํฌํŠธ: 9090 (์„œ๋น„์Šค ํฌํŠธ์™€ ๋ถ„๋ฆฌ)
- Virtual Threads ํ™œ์„ฑํ™” (`spring.threads.virtual.enabled: true`)
- OCI Object Storage: `~/.oci/config` ํŒŒ์ผ ๊ธฐ๋ฐ˜ ์ธ์ฆ, `OCI_NAMESPACE`, `OCI_IMAGE_BUCKET_NAME`, `OCI_PDF_BUCKET_NAME` ํ™˜๊ฒฝ๋ณ€์ˆ˜
- OCI Object Storage: `~/.oci/config` ํŒŒ์ผ ๊ธฐ๋ฐ˜ ์ธ์ฆ, `OCI_NAMESPACE`, `OCI_IMAGE_BUCKET_NAME`,
`OCI_PDF_BUCKET_NAME` ํ™˜๊ฒฝ๋ณ€์ˆ˜
- Google Cloud: Vertex AI + GCS (ADC ์ธ์ฆ)
- `spring.ai.google.genai.project-id`: GCP ํ”„๋กœ์ ํŠธ ID
- `spring.ai.google.genai.location`: GCP ์—”๋“œํฌ์ธํŠธ (ํ˜„์žฌ: `global`)
- `GCS_BUCKET_NAME`: GCS ๋ฒ„ํ‚ท ์ด๋ฆ„ (๊ธฐ๋ณธ๊ฐ’: `q-asker-ai-files`)
- ๋กœ์ปฌ: `gcloud auth application-default login`, ํ”„๋กœ๋•์…˜: ์„œ๋น„์Šค ๊ณ„์ •
- `spring.ai.google.genai.project-id`: GCP ํ”„๋กœ์ ํŠธ ID
- `spring.ai.google.genai.location`: GCP ์—”๋“œํฌ์ธํŠธ (ํ˜„์žฌ: `global`)
- `GCS_BUCKET_NAME`: GCS ๋ฒ„ํ‚ท ์ด๋ฆ„ (๊ธฐ๋ณธ๊ฐ’: `q-asker-ai-files`)
- ๋กœ์ปฌ: `gcloud auth application-default login`, ํ”„๋กœ๋•์…˜: ์„œ๋น„์Šค ๊ณ„์ •
- DDoS ๋ฐฉ์–ด: Cloudflare Free (`api.q-asker.com`๋งŒ ํ”„๋ก์‹œ ํ™œ์„ฑํ™”)
- SSL/HTTPS: Cloudflare (Universal SSL) โ†’ Nginx (Origin CA TLS), Full (Strict) ๋ชจ๋“œ
- Origin ์ธ์ฆ์„œ: Cloudflare Origin CA (15๋…„ ์œ ํšจ)
- ์ธ์ฆ์„œ ๊ฒฝ๋กœ: `/etc/ssl/cloudflare/api.q-asker.com.pem`, `.key`
- OCI NSG: 80/443 ์ธ๋ฐ”์šด๋“œ Cloudflare IP ๋Œ€์—ญ๋งŒ ํ—ˆ์šฉ
- Origin ์ธ์ฆ์„œ: Cloudflare Origin CA (15๋…„ ์œ ํšจ)
- ์ธ์ฆ์„œ ๊ฒฝ๋กœ: `/etc/ssl/cloudflare/api.q-asker.com.pem`, `.key`
- OCI NSG: 80/443 ์ธ๋ฐ”์šด๋“œ Cloudflare IP ๋Œ€์—ญ๋งŒ ํ—ˆ์šฉ

## ๊ฐœ๋ฐœ ๋„๊ตฌ ๋ฐ ์„ค์ •

- **๋นŒ๋“œ**: Gradle 8.14.3 (Groovy DSL)
- **JDK**: 21 (Gradle Toolchain ์ž๋™ ๊ด€๋ฆฌ)
- **ํฌ๋งทํ„ฐ**: Spotless + Google Java Format 1.25.2
- `./gradlew spotlessApply` โ€” ํฌ๋งท ์ ์šฉ
- `./gradlew spotlessCheck` โ€” ํฌ๋งท ๊ฒ€์ฆ
- `./gradlew spotlessApply` โ€” ํฌ๋งท ์ ์šฉ
- `./gradlew spotlessCheck` โ€” ํฌ๋งท ๊ฒ€์ฆ
- **Git Hooks** (`.githooks/`)
- `prepare-commit-msg` โ€” ๋ธŒ๋žœ์น˜์—์„œ JIRA ํ‹ฐ์ผ“(`[A-Z]+-[0-9]+`) ๊ฐ์ง€ํ•˜์—ฌ ์ปค๋ฐ‹ ๋ฉ”์‹œ์ง€ ์ ‘๋‘์‚ฌ ์ž๋™ ์ถ”๊ฐ€
- `pre-commit` โ€” `spotlessCheck` ์‹คํ–‰, ์œ„๋ฐ˜ ์‹œ ์ปค๋ฐ‹ ์ฐจ๋‹จ
- `pre-push` โ€” `spotlessCheck` ์‹คํ–‰, ์œ„๋ฐ˜ ์‹œ ํ‘ธ์‹œ ์ฐจ๋‹จ
- `prepare-commit-msg` โ€” ๋ธŒ๋žœ์น˜์—์„œ JIRA ํ‹ฐ์ผ“(`[A-Z]+-[0-9]+`) ๊ฐ์ง€ํ•˜์—ฌ ์ปค๋ฐ‹ ๋ฉ”์‹œ์ง€ ์ ‘๋‘์‚ฌ ์ž๋™ ์ถ”๊ฐ€
- `pre-commit` โ€” `spotlessCheck` ์‹คํ–‰, ์œ„๋ฐ˜ ์‹œ ์ปค๋ฐ‹ ์ฐจ๋‹จ
- `pre-push` โ€” `spotlessCheck` ์‹คํ–‰, ์œ„๋ฐ˜ ์‹œ ํ‘ธ์‹œ ์ฐจ๋‹จ
- **CI/CD**: GitHub Actions
- `ci-check-code-convention.yml` โ€” PR ํฌ๋งท ๊ฒ€์ฆ
- `ci-auto-version-bump.yml` โ€” ์ž๋™ ๋ฒ„์ „ ๋ฒ”ํ”„
- `ci-update-api-docs.yml` โ€” OpenAPI ์ŠคํŽ™ ์ž๋™ ๊ฐฑ์‹ 
- `cd-prod_deploy.yml` โ€” ์šด์˜ ๋ฐฐํฌ

## gstack

- ๋ชจ๋“  ์›น ๋ธŒ๋ผ์šฐ์ง•์€ gstack์˜ `/browse` ์Šคํ‚ฌ์„ ์‚ฌ์šฉํ•œ๋‹ค. `mcp__claude-in-chrome__*` ๋„๊ตฌ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค.
- ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ‚ฌ: `/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/design-shotgun`, `/design-html`, `/review`, `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/setup-deploy`, `/retro`, `/investigate`, `/document-release`, `/codex`, `/cso`, `/autoplan`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade`, `/learn`

## Skill routing

When the user's request matches an available skill, ALWAYS invoke it using the Skill
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
The skill has specialized workflows that produce better results than ad-hoc answers.

Key routing rules:
- Product ideas, "is this worth building", brainstorming โ†’ invoke office-hours
- Bugs, errors, "why is this broken", 500 errors โ†’ invoke investigate
- Ship, deploy, push, create PR โ†’ invoke ship
- QA, test the site, find bugs โ†’ invoke qa
- Code review, check my diff โ†’ invoke review
- Update docs after shipping โ†’ invoke document-release
- Weekly retro โ†’ invoke retro
- Design system, brand โ†’ invoke design-consultation
- Visual audit, design polish โ†’ invoke design-review
- Architecture review โ†’ invoke plan-eng-review
- `ci-check-code-convention.yml` โ€” PR ํฌ๋งท ๊ฒ€์ฆ
- `ci-auto-version-bump.yml` โ€” ์ž๋™ ๋ฒ„์ „ ๋ฒ”ํ”„
- `ci-update-api-docs.yml` โ€” OpenAPI ์ŠคํŽ™ ์ž๋™ ๊ฐฑ์‹ 
- `cd-prod_deploy.yml` โ€” ์šด์˜ ๋ฐฐํฌ
2 changes: 1 addition & 1 deletion app/src/main/resources/config/app-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ q-asker:
file-validation:
max-file-name-length: 255
allowed-extensions: application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint
max-file-size: 36_700_160
max-file-size: 52_428_800

server:
shutdown: graceful
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ plugins {
group = "com.icc.qasker"
// ํ”„๋กœ์ ํŠธ ๋ฒ„์ „ (Docker ์ด๋ฏธ์ง€ ํƒœ๊ทธ, ๋ฐฐํฌ ์•„ํ‹ฐํŒฉํŠธ ๋ฒ„์ „์— ์‚ฌ์šฉ)
// ์˜ˆ: jib์œผ๋กœ ๋นŒ๋“œํ•˜๋ฉด Docker ์ด๋ฏธ์ง€์— "1.7.0" ํƒœ๊ทธ๊ฐ€ ๋ถ™์Œ
version = "3.0.6"
version = "3.1.0"

// Git hooks ๊ฒฝ๋กœ๋ฅผ .githooks/๋กœ ์ž๋™ ์„ค์ •
// ์˜ˆ: ./gradlew build ์‹คํ–‰ ์‹œ ์ž๋™์œผ๋กœ git config core.hooksPath .githooks ์ ์šฉ
Expand Down
10 changes: 10 additions & 0 deletions modules/quiz-make/impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,14 @@ dependencies {
// ์˜ˆ: ์„œ๋ฒ„ ๊ธฐ๋™ ํ›„ /swagger-ui/index.html ์ ‘์†ํ•˜๋ฉด API ๋ชฉ๋กยทํ…Œ์ŠคํŠธ UI๊ฐ€ ์ œ๊ณต๋จ
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8"

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ํ…Œ์ŠคํŠธ
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation platform("org.junit:junit-bom:5.12.2")
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}

test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ private void processGenerationAsync(
GenerationRequestToAI requestToAI =
GenerationRequestToAI.builder()
.fileUrl(request.uploadedUrl())
.strategyValue(request.quizType().name())
.strategyValue(request.quizType().toAiStrategyName())
.language(request.language().name())
.quizCount(request.quizCount())
.referencePages(request.pageNumbers())
Expand All @@ -113,7 +113,9 @@ private void processGenerationAsync(

// 2. ์„ ํƒ์ง€ ์…”ํ”Œ
QuizType quizType = request.quizType();
if (quizType == QuizType.MULTIPLE || quizType == QuizType.BLANK) {
if (quizType == QuizType.MULTIPLE
|| quizType == QuizType.BLANK
|| quizType == QuizType.REAL_BLANK) {
for (var quiz : problemSet.getQuiz()) {
if (!CollectionUtils.isEmpty(quiz.getSelections())) {
List<SelectionsOfAI> shuffled = new ArrayList<>(quiz.getSelections());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.icc.qasker.quizmake.service.generation;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.icc.qasker.ai.dto.GenerationRequestToAI;
import com.icc.qasker.global.component.HashUtil;
import com.icc.qasker.quizmake.SseNotificationService;
import com.icc.qasker.quizmake.adapter.AIServerAdapter;
import com.icc.qasker.quizmake.dto.ferequest.GenerationRequest;
import com.icc.qasker.quizmake.dto.ferequest.enums.Language;
import com.icc.qasker.quizset.QuizCommandService;
import com.icc.qasker.quizset.QuizQueryService;
import com.icc.qasker.quizset.dto.ferequest.enums.QuizType;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

class GenerationCommandServiceImplTest {

private AIServerAdapter aiServerAdapter;
private SseNotificationService notificationService;
private QuizCommandService quizCommandService;
private QuizQueryService quizQueryService;
private HashUtil hashUtil;
private GenerationResultRecorder resultRecorder;
private GenerationCommandServiceImpl service;

@BeforeEach
void setUp() {
aiServerAdapter = mock(AIServerAdapter.class);
notificationService = mock(SseNotificationService.class);
quizCommandService = mock(QuizCommandService.class);
quizQueryService = mock(QuizQueryService.class);
hashUtil = mock(HashUtil.class);
resultRecorder = mock(GenerationResultRecorder.class);

when(quizCommandService.initProblemSet(any(), any(), any(), anyInt(), any(), any(), any()))
.thenReturn(1L);

service =
new GenerationCommandServiceImpl(
aiServerAdapter,
notificationService,
quizCommandService,
quizQueryService,
hashUtil,
resultRecorder);
}

@Test
@DisplayName("REAL_BLANK ์š”์ฒญ์€ DB์—๋Š” REAL_BLANK ๊ทธ๋Œ€๋กœ ์ €์žฅํ•œ๋‹ค")
void real_blank_request_persists_real_blank_to_db() {
GenerationRequest request = request(QuizType.REAL_BLANK);

service.triggerGeneration("user-1", request);

verify(quizCommandService, timeout(2000))
.initProblemSet(
eq("user-1"), any(), any(), anyInt(), eq(QuizType.REAL_BLANK), any(), any());
}

@Test
@DisplayName("REAL_BLANK ์š”์ฒญ์€ AI ์„œ๋ฒ„์— strategyValue=BLANK๋กœ ์ „๋‹ฌํ•œ๋‹ค")
void real_blank_request_calls_ai_with_blank_strategy() {
GenerationRequest request = request(QuizType.REAL_BLANK);

service.triggerGeneration("user-1", request);

ArgumentCaptor<GenerationRequestToAI> captor =
ArgumentCaptor.forClass(GenerationRequestToAI.class);
verify(aiServerAdapter, timeout(2000)).streamRequest(captor.capture());
assertThat(captor.getValue().strategyValue()).isEqualTo("BLANK");
}

@Test
@DisplayName("BLANK ์š”์ฒญ์€ AI ์„œ๋ฒ„์— strategyValue=BLANK๋กœ ์ „๋‹ฌํ•œ๋‹ค (ํšŒ๊ท€ ๋ฐฉ์ง€)")
void blank_request_calls_ai_with_blank_strategy() {
GenerationRequest request = request(QuizType.BLANK);

service.triggerGeneration("user-1", request);

ArgumentCaptor<GenerationRequestToAI> captor =
ArgumentCaptor.forClass(GenerationRequestToAI.class);
verify(aiServerAdapter, timeout(2000)).streamRequest(captor.capture());
assertThat(captor.getValue().strategyValue()).isEqualTo("BLANK");
}

@Test
@DisplayName("MULTIPLE ์š”์ฒญ์€ AI ์„œ๋ฒ„์— strategyValue=MULTIPLE๋กœ ์ „๋‹ฌํ•œ๋‹ค (ํšŒ๊ท€ ๋ฐฉ์ง€)")
void multiple_request_calls_ai_with_multiple_strategy() {
GenerationRequest request = request(QuizType.MULTIPLE);

service.triggerGeneration("user-1", request);

ArgumentCaptor<GenerationRequestToAI> captor =
ArgumentCaptor.forClass(GenerationRequestToAI.class);
verify(aiServerAdapter, timeout(2000)).streamRequest(captor.capture());
assertThat(captor.getValue().strategyValue()).isEqualTo("MULTIPLE");
}

private GenerationRequest request(QuizType quizType) {
return new GenerationRequest(
null,
UUID.randomUUID().toString(),
"https://example.com/file.pdf",
"title",
5,
quizType,
List.of(1, 2),
Language.KO);
}
}
11 changes: 11 additions & 0 deletions modules/quiz-set/api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@ dependencies {
// Spring WebMVC: SseEmitter ๋“ฑ ์›น ๊ด€๋ จ ํƒ€์ž… ์ œ๊ณต
// ์˜ˆ: GenerationService ์ธํ„ฐํŽ˜์ด์Šค์—์„œ SseEmitter ๋ฐ˜ํ™˜ ํƒ€์ž… ์‚ฌ์šฉ
implementation "org.springframework:spring-webmvc"

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ํ…Œ์ŠคํŠธ
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation platform("org.junit:junit-bom:5.12.2")
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}

test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
public enum QuizType {
MULTIPLE,
BLANK,
REAL_BLANK,
OX,
ESSAY
ESSAY;

public String toAiStrategyName() {
return this == REAL_BLANK ? BLANK.name() : name();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.icc.qasker.quizset.dto.ferequest.enums;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class QuizTypeTest {

@Test
@DisplayName("REAL_BLANK๋Š” AI ์ „๋žต ์ด๋ฆ„์„ BLANK๋กœ ๋งคํ•‘ํ•œ๋‹ค")
void real_blank_maps_to_blank() {
assertThat(QuizType.REAL_BLANK.toAiStrategyName()).isEqualTo("BLANK");
}

@Test
@DisplayName("BLANK๋Š” ์ž๊ธฐ ์ž์‹ ์˜ ์ด๋ฆ„์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค")
void blank_keeps_own_name() {
assertThat(QuizType.BLANK.toAiStrategyName()).isEqualTo("BLANK");
}

@Test
@DisplayName("MULTIPLE์€ ์ž๊ธฐ ์ž์‹ ์˜ ์ด๋ฆ„์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค")
void multiple_keeps_own_name() {
assertThat(QuizType.MULTIPLE.toAiStrategyName()).isEqualTo("MULTIPLE");
}

@Test
@DisplayName("OX๋Š” ์ž๊ธฐ ์ž์‹ ์˜ ์ด๋ฆ„์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค")
void ox_keeps_own_name() {
assertThat(QuizType.OX.toAiStrategyName()).isEqualTo("OX");
}

@Test
@DisplayName("ESSAY๋Š” ์ž๊ธฐ ์ž์‹ ์˜ ์ด๋ฆ„์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค")
void essay_keeps_own_name() {
assertThat(QuizType.ESSAY.toAiStrategyName()).isEqualTo("ESSAY");
}
}
Loading